完善语音房相关

This commit is contained in:
zhx 2026-05-12 19:34:47 +08:00
parent 422ef05adc
commit 441237fab7
103 changed files with 10797 additions and 1552 deletions

View File

@ -1,7 +1,7 @@
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron mysql redis gateway-service room-service wallet-service user-service activity-service cron-service
COMPOSE_SERVICES := mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service
SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity cs cron game games mysql redis gateway-service room-service wallet-service user-service activity-service cron-service game-service
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(SERVICE_TOKEN)))))))))
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(if $(filter cs cron,$(SERVICE_TOKEN)),cron-service,$(if $(filter game games,$(SERVICE_TOKEN)),game-service,$(SERVICE_TOKEN))))))))))
ADMIN_CONFIG ?= configs/config.yaml
.PHONY: proto vet test build admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
@ -19,6 +19,7 @@ proto:
proto/user/v1/host.proto \
proto/activity/v1/activity.proto \
proto/room/v1/room.proto \
proto/game/v1/game.proto \
proto/wallet/v1/wallet.proto \
proto/events/room/v1/events.proto
@ -77,7 +78,7 @@ up:
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service game-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \
@ -139,13 +140,13 @@ down:
check-service:
@if [ -z "$(SERVICE_ID)" ]; then \
echo "Service is required. Example: make restart rs"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron mysql redis"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \
echo "Services: $(COMPOSE_SERVICES)"; \
exit 1; \
fi
@if ! printf '%s\n' $(COMPOSE_SERVICES) | grep -qx "$(SERVICE_ID)"; then \
echo "Unknown service: $(SERVICE_ID)"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron mysql redis"; \
echo "Aliases: gs/gateway rs/room ws/wallet us/user as/activity cs/cron game/games mysql redis"; \
echo "Services: $(COMPOSE_SERVICES)"; \
exit 1; \
fi

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -461,6 +461,7 @@ type RoomMicChanged struct {
Reason string `protobuf:"bytes,8,opt,name=reason,proto3" json:"reason,omitempty"`
PublishDeadlineMs int64 `protobuf:"varint,9,opt,name=publish_deadline_ms,json=publishDeadlineMs,proto3" json:"publish_deadline_ms,omitempty"`
PublishEventTimeMs int64 `protobuf:"varint,10,opt,name=publish_event_time_ms,json=publishEventTimeMs,proto3" json:"publish_event_time_ms,omitempty"`
MicMuted bool `protobuf:"varint,11,opt,name=mic_muted,json=micMuted,proto3" json:"mic_muted,omitempty"`
}
func (x *RoomMicChanged) Reset() {
@ -563,6 +564,13 @@ func (x *RoomMicChanged) GetPublishEventTimeMs() int64 {
return 0
}
func (x *RoomMicChanged) GetMicMuted() bool {
if x != nil {
return x.MicMuted
}
return false
}
// RoomMicSeatLocked 表达单个麦位锁定状态变更。
type RoomMicSeatLocked struct {
state protoimpl.MessageState
@ -1249,7 +1257,7 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0xee, 0x02,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x8b, 0x03,
0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64,
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
@ -1272,88 +1280,90 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x68, 0x44, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73, 0x12, 0x31, 0x0a, 0x15, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d,
0x65, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x70, 0x75, 0x62, 0x6c,
0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x22, 0x68,
0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63,
0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f,
0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f,
0x6e, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f,
0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d,
0x43, 0x68, 0x61, 0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
0x22, 0x76, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x1b,
0x0a, 0x09, 0x6d, 0x69, 0x63, 0x5f, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28,
0x08, 0x52, 0x08, 0x6d, 0x69, 0x63, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x68, 0x0a, 0x11, 0x52,
0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a,
0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c,
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61,
0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02,
0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a,
0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65,
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e,
0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x8b, 0x01, 0x0a, 0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x6f,
0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x12, 0x22, 0x0a,
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x27, 0x0a, 0x10, 0x6f, 0x6c, 0x64, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6f, 0x6c, 0x64,
0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x6e, 0x65,
0x77, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d,
0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18,
0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52,
0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x8b, 0x01, 0x0a, 0x13, 0x52, 0x6f, 0x6f,
0x6d, 0x48, 0x6f, 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64,
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x6f, 0x6c, 0x64, 0x5f, 0x68, 0x6f, 0x73, 0x74,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d,
0x6f, 0x6c, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a,
0x10, 0x6e, 0x65, 0x77, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x48, 0x6f, 0x73, 0x74,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73,
0x65, 0x72, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
0x52, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
0x73, 0x65, 0x72, 0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a,
0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55,
0x6e, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
0x64, 0x22, 0xaa, 0x02, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65,
0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14,
0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d,
0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72,
0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61,
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55, 0x6e, 0x62, 0x61,
0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17,
0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76,
0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74,
0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67,
0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70,
0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f,
0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20,
0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a,
0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65,
0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f,
0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a,
0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06,
0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d,
0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xaa,
0x02, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12,
0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67,
0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69,
0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c,
0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65,
0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10,
0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64,
0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73,
0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x0f, 0x52,
0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14,
0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64,
0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f,
0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52,
0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67,
0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31,
0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (

View File

@ -69,6 +69,7 @@ message RoomMicChanged {
string reason = 8;
int64 publish_deadline_ms = 9;
int64 publish_event_time_ms = 10;
bool mic_muted = 11;
}
// RoomMicSeatLocked

1883
api/proto/game/v1/game.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,175 @@
syntax = "proto3";
package hyapp.game.v1;
option go_package = "hyapp.local/api/proto/game/v1;gamev1";
// RequestMeta game-service RPC
message RequestMeta {
string request_id = 1;
string caller = 2;
string gateway_node_id = 3;
int64 actor_user_id = 4;
int64 sent_at_ms = 5;
string app_code = 6;
}
message GamePlatform {
string app_code = 1;
string platform_code = 2;
string platform_name = 3;
string status = 4;
string api_base_url = 5;
int32 sort_order = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
}
message GameCatalogItem {
string app_code = 1;
string game_id = 2;
string platform_code = 3;
string provider_game_id = 4;
string game_name = 5;
string category = 6;
string icon_url = 7;
string cover_url = 8;
string launch_mode = 9;
string orientation = 10;
int64 min_coin = 11;
string status = 12;
int32 sort_order = 13;
repeated string tags = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
}
message AppGame {
string game_id = 1;
string platform_code = 2;
string name_key = 3;
string name = 4;
string icon_url = 5;
string cover_url = 6;
string category = 7;
string launch_mode = 8;
string orientation = 9;
int64 min_coin = 10;
bool enabled = 11;
bool maintenance = 12;
int32 sort_order = 13;
}
message ListGamesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string scene = 3;
string room_id = 4;
int64 region_id = 5;
string language = 6;
string client_platform = 7;
}
message ListGamesResponse {
repeated AppGame games = 1;
int64 server_time_ms = 2;
}
message LaunchGameRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string display_user_id = 3;
string game_id = 4;
string scene = 5;
string room_id = 6;
}
message LaunchGameResponse {
string session_id = 1;
string launch_url = 2;
int64 expires_at_ms = 3;
string orientation = 4;
int64 server_time_ms = 5;
}
message CallbackRequest {
RequestMeta meta = 1;
string platform_code = 2;
string operation = 3;
bytes raw_body = 4;
map<string, string> headers = 5;
map<string, string> query = 6;
string remote_addr = 7;
}
message CallbackResponse {
bytes raw_body = 1;
string content_type = 2;
}
message ListPlatformsRequest {
RequestMeta meta = 1;
string status = 2;
}
message ListPlatformsResponse {
repeated GamePlatform platforms = 1;
int64 server_time_ms = 2;
}
message UpsertPlatformRequest {
RequestMeta meta = 1;
GamePlatform platform = 2;
}
message PlatformResponse {
GamePlatform platform = 1;
int64 server_time_ms = 2;
}
message ListCatalogRequest {
RequestMeta meta = 1;
string platform_code = 2;
string status = 3;
int32 page_size = 4;
string cursor = 5;
}
message ListCatalogResponse {
repeated GameCatalogItem games = 1;
string next_cursor = 2;
int64 server_time_ms = 3;
}
message UpsertCatalogRequest {
RequestMeta meta = 1;
GameCatalogItem game = 2;
}
message CatalogResponse {
GameCatalogItem game = 1;
int64 server_time_ms = 2;
}
message SetGameStatusRequest {
RequestMeta meta = 1;
string game_id = 2;
string status = 3;
}
service GameAppService {
rpc ListGames(ListGamesRequest) returns (ListGamesResponse);
rpc LaunchGame(LaunchGameRequest) returns (LaunchGameResponse);
}
service GameCallbackService {
rpc HandleCallback(CallbackRequest) returns (CallbackResponse);
}
service GameAdminService {
rpc ListPlatforms(ListPlatformsRequest) returns (ListPlatformsResponse);
rpc UpsertPlatform(UpsertPlatformRequest) returns (PlatformResponse);
rpc ListCatalog(ListCatalogRequest) returns (ListCatalogResponse);
rpc UpsertCatalog(UpsertCatalogRequest) returns (CatalogResponse);
rpc SetGameStatus(SetGameStatusRequest) returns (CatalogResponse);
}

View File

@ -0,0 +1,515 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.29.3
// source: proto/game/v1/game.proto
package gamev1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GameAppService_ListGames_FullMethodName = "/hyapp.game.v1.GameAppService/ListGames"
GameAppService_LaunchGame_FullMethodName = "/hyapp.game.v1.GameAppService/LaunchGame"
)
// GameAppServiceClient is the client API for GameAppService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GameAppServiceClient interface {
ListGames(ctx context.Context, in *ListGamesRequest, opts ...grpc.CallOption) (*ListGamesResponse, error)
LaunchGame(ctx context.Context, in *LaunchGameRequest, opts ...grpc.CallOption) (*LaunchGameResponse, error)
}
type gameAppServiceClient struct {
cc grpc.ClientConnInterface
}
func NewGameAppServiceClient(cc grpc.ClientConnInterface) GameAppServiceClient {
return &gameAppServiceClient{cc}
}
func (c *gameAppServiceClient) ListGames(ctx context.Context, in *ListGamesRequest, opts ...grpc.CallOption) (*ListGamesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListGamesResponse)
err := c.cc.Invoke(ctx, GameAppService_ListGames_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAppServiceClient) LaunchGame(ctx context.Context, in *LaunchGameRequest, opts ...grpc.CallOption) (*LaunchGameResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LaunchGameResponse)
err := c.cc.Invoke(ctx, GameAppService_LaunchGame_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameAppServiceServer is the server API for GameAppService service.
// All implementations must embed UnimplementedGameAppServiceServer
// for forward compatibility.
type GameAppServiceServer interface {
ListGames(context.Context, *ListGamesRequest) (*ListGamesResponse, error)
LaunchGame(context.Context, *LaunchGameRequest) (*LaunchGameResponse, error)
mustEmbedUnimplementedGameAppServiceServer()
}
// UnimplementedGameAppServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGameAppServiceServer struct{}
func (UnimplementedGameAppServiceServer) ListGames(context.Context, *ListGamesRequest) (*ListGamesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListGames not implemented")
}
func (UnimplementedGameAppServiceServer) LaunchGame(context.Context, *LaunchGameRequest) (*LaunchGameResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LaunchGame not implemented")
}
func (UnimplementedGameAppServiceServer) mustEmbedUnimplementedGameAppServiceServer() {}
func (UnimplementedGameAppServiceServer) testEmbeddedByValue() {}
// UnsafeGameAppServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GameAppServiceServer will
// result in compilation errors.
type UnsafeGameAppServiceServer interface {
mustEmbedUnimplementedGameAppServiceServer()
}
func RegisterGameAppServiceServer(s grpc.ServiceRegistrar, srv GameAppServiceServer) {
// If the following call pancis, it indicates UnimplementedGameAppServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GameAppService_ServiceDesc, srv)
}
func _GameAppService_ListGames_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListGamesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).ListGames(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_ListGames_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).ListGames(ctx, req.(*ListGamesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAppService_LaunchGame_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LaunchGameRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAppServiceServer).LaunchGame(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAppService_LaunchGame_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAppServiceServer).LaunchGame(ctx, req.(*LaunchGameRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameAppService_ServiceDesc is the grpc.ServiceDesc for GameAppService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GameAppService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.game.v1.GameAppService",
HandlerType: (*GameAppServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListGames",
Handler: _GameAppService_ListGames_Handler,
},
{
MethodName: "LaunchGame",
Handler: _GameAppService_LaunchGame_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",
}
const (
GameCallbackService_HandleCallback_FullMethodName = "/hyapp.game.v1.GameCallbackService/HandleCallback"
)
// GameCallbackServiceClient is the client API for GameCallbackService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GameCallbackServiceClient interface {
HandleCallback(ctx context.Context, in *CallbackRequest, opts ...grpc.CallOption) (*CallbackResponse, error)
}
type gameCallbackServiceClient struct {
cc grpc.ClientConnInterface
}
func NewGameCallbackServiceClient(cc grpc.ClientConnInterface) GameCallbackServiceClient {
return &gameCallbackServiceClient{cc}
}
func (c *gameCallbackServiceClient) HandleCallback(ctx context.Context, in *CallbackRequest, opts ...grpc.CallOption) (*CallbackResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CallbackResponse)
err := c.cc.Invoke(ctx, GameCallbackService_HandleCallback_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameCallbackServiceServer is the server API for GameCallbackService service.
// All implementations must embed UnimplementedGameCallbackServiceServer
// for forward compatibility.
type GameCallbackServiceServer interface {
HandleCallback(context.Context, *CallbackRequest) (*CallbackResponse, error)
mustEmbedUnimplementedGameCallbackServiceServer()
}
// UnimplementedGameCallbackServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGameCallbackServiceServer struct{}
func (UnimplementedGameCallbackServiceServer) HandleCallback(context.Context, *CallbackRequest) (*CallbackResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HandleCallback not implemented")
}
func (UnimplementedGameCallbackServiceServer) mustEmbedUnimplementedGameCallbackServiceServer() {}
func (UnimplementedGameCallbackServiceServer) testEmbeddedByValue() {}
// UnsafeGameCallbackServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GameCallbackServiceServer will
// result in compilation errors.
type UnsafeGameCallbackServiceServer interface {
mustEmbedUnimplementedGameCallbackServiceServer()
}
func RegisterGameCallbackServiceServer(s grpc.ServiceRegistrar, srv GameCallbackServiceServer) {
// If the following call pancis, it indicates UnimplementedGameCallbackServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GameCallbackService_ServiceDesc, srv)
}
func _GameCallbackService_HandleCallback_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CallbackRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameCallbackServiceServer).HandleCallback(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameCallbackService_HandleCallback_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameCallbackServiceServer).HandleCallback(ctx, req.(*CallbackRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameCallbackService_ServiceDesc is the grpc.ServiceDesc for GameCallbackService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GameCallbackService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.game.v1.GameCallbackService",
HandlerType: (*GameCallbackServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "HandleCallback",
Handler: _GameCallbackService_HandleCallback_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",
}
const (
GameAdminService_ListPlatforms_FullMethodName = "/hyapp.game.v1.GameAdminService/ListPlatforms"
GameAdminService_UpsertPlatform_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertPlatform"
GameAdminService_ListCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/ListCatalog"
GameAdminService_UpsertCatalog_FullMethodName = "/hyapp.game.v1.GameAdminService/UpsertCatalog"
GameAdminService_SetGameStatus_FullMethodName = "/hyapp.game.v1.GameAdminService/SetGameStatus"
)
// GameAdminServiceClient is the client API for GameAdminService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GameAdminServiceClient interface {
ListPlatforms(ctx context.Context, in *ListPlatformsRequest, opts ...grpc.CallOption) (*ListPlatformsResponse, error)
UpsertPlatform(ctx context.Context, in *UpsertPlatformRequest, opts ...grpc.CallOption) (*PlatformResponse, error)
ListCatalog(ctx context.Context, in *ListCatalogRequest, opts ...grpc.CallOption) (*ListCatalogResponse, error)
UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error)
}
type gameAdminServiceClient struct {
cc grpc.ClientConnInterface
}
func NewGameAdminServiceClient(cc grpc.ClientConnInterface) GameAdminServiceClient {
return &gameAdminServiceClient{cc}
}
func (c *gameAdminServiceClient) ListPlatforms(ctx context.Context, in *ListPlatformsRequest, opts ...grpc.CallOption) (*ListPlatformsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListPlatformsResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListPlatforms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) UpsertPlatform(ctx context.Context, in *UpsertPlatformRequest, opts ...grpc.CallOption) (*PlatformResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PlatformResponse)
err := c.cc.Invoke(ctx, GameAdminService_UpsertPlatform_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) ListCatalog(ctx context.Context, in *ListCatalogRequest, opts ...grpc.CallOption) (*ListCatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListCatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_ListCatalog_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) UpsertCatalog(ctx context.Context, in *UpsertCatalogRequest, opts ...grpc.CallOption) (*CatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_UpsertCatalog_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *gameAdminServiceClient) SetGameStatus(ctx context.Context, in *SetGameStatusRequest, opts ...grpc.CallOption) (*CatalogResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CatalogResponse)
err := c.cc.Invoke(ctx, GameAdminService_SetGameStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GameAdminServiceServer is the server API for GameAdminService service.
// All implementations must embed UnimplementedGameAdminServiceServer
// for forward compatibility.
type GameAdminServiceServer interface {
ListPlatforms(context.Context, *ListPlatformsRequest) (*ListPlatformsResponse, error)
UpsertPlatform(context.Context, *UpsertPlatformRequest) (*PlatformResponse, error)
ListCatalog(context.Context, *ListCatalogRequest) (*ListCatalogResponse, error)
UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error)
SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error)
mustEmbedUnimplementedGameAdminServiceServer()
}
// UnimplementedGameAdminServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGameAdminServiceServer struct{}
func (UnimplementedGameAdminServiceServer) ListPlatforms(context.Context, *ListPlatformsRequest) (*ListPlatformsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListPlatforms not implemented")
}
func (UnimplementedGameAdminServiceServer) UpsertPlatform(context.Context, *UpsertPlatformRequest) (*PlatformResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertPlatform not implemented")
}
func (UnimplementedGameAdminServiceServer) ListCatalog(context.Context, *ListCatalogRequest) (*ListCatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCatalog not implemented")
}
func (UnimplementedGameAdminServiceServer) UpsertCatalog(context.Context, *UpsertCatalogRequest) (*CatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertCatalog not implemented")
}
func (UnimplementedGameAdminServiceServer) SetGameStatus(context.Context, *SetGameStatusRequest) (*CatalogResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetGameStatus not implemented")
}
func (UnimplementedGameAdminServiceServer) mustEmbedUnimplementedGameAdminServiceServer() {}
func (UnimplementedGameAdminServiceServer) testEmbeddedByValue() {}
// UnsafeGameAdminServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GameAdminServiceServer will
// result in compilation errors.
type UnsafeGameAdminServiceServer interface {
mustEmbedUnimplementedGameAdminServiceServer()
}
func RegisterGameAdminServiceServer(s grpc.ServiceRegistrar, srv GameAdminServiceServer) {
// If the following call pancis, it indicates UnimplementedGameAdminServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GameAdminService_ServiceDesc, srv)
}
func _GameAdminService_ListPlatforms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListPlatformsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListPlatforms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListPlatforms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListPlatforms(ctx, req.(*ListPlatformsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_UpsertPlatform_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertPlatformRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).UpsertPlatform(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_UpsertPlatform_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).UpsertPlatform(ctx, req.(*UpsertPlatformRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_ListCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListCatalogRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).ListCatalog(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_ListCatalog_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).ListCatalog(ctx, req.(*ListCatalogRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_UpsertCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpsertCatalogRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).UpsertCatalog(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_UpsertCatalog_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).UpsertCatalog(ctx, req.(*UpsertCatalogRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GameAdminService_SetGameStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetGameStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GameAdminServiceServer).SetGameStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GameAdminService_SetGameStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GameAdminServiceServer).SetGameStatus(ctx, req.(*SetGameStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
// GameAdminService_ServiceDesc is the grpc.ServiceDesc for GameAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GameAdminService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.game.v1.GameAdminService",
HandlerType: (*GameAdminServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListPlatforms",
Handler: _GameAdminService_ListPlatforms_Handler,
},
{
MethodName: "UpsertPlatform",
Handler: _GameAdminService_UpsertPlatform_Handler,
},
{
MethodName: "ListCatalog",
Handler: _GameAdminService_ListCatalog_Handler,
},
{
MethodName: "UpsertCatalog",
Handler: _GameAdminService_UpsertCatalog_Handler,
},
{
MethodName: "SetGameStatus",
Handler: _GameAdminService_SetGameStatus_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/game/v1/game.proto",
}

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,8 @@ message SeatState {
int64 mic_session_room_version = 7;
// last_publish_event_time_ms RTC/
int64 last_publish_event_time_ms = 8;
// mic_muted UI
bool mic_muted = 9;
}
// RankItem
@ -78,6 +80,7 @@ message RoomSnapshot {
int64 version = 15;
string app_code = 16;
string room_short_id = 17;
int64 visible_region_id = 18;
}
// CreateRoomRequest
@ -234,6 +237,21 @@ message ConfirmMicPublishingResponse {
RoomSnapshot room = 3;
}
// SetMicMuteRequest
message SetMicMuteRequest {
RequestMeta meta = 1;
// target_user_id actor_user_id
int64 target_user_id = 2;
bool muted = 3;
}
// SetMicMuteResponse
message SetMicMuteResponse {
CommandResult result = 1;
int32 seat_no = 2;
RoomSnapshot room = 3;
}
// ApplyRTCEventRequest RTC Room Cell
// event_type audio_startedaudio_stoppedroom_exited
message ApplyRTCEventRequest {
@ -343,9 +361,13 @@ message UnbanUserResponse {
// SendGiftRequest
message SendGiftRequest {
RequestMeta meta = 1;
// target_user_id 使 target_user_ids
int64 target_user_id = 2;
string gift_id = 3;
int32 gift_count = 4;
// target_type user all_micall_roomcouple HTTP
string target_type = 5;
repeated int64 target_user_ids = 6;
}
// SendGiftResponse
@ -493,6 +515,23 @@ message GetRoomSnapshotResponse {
int64 server_time_ms = 2;
}
// ListRoomOnlineUsersRequest 线 RoomSnapshot
message ListRoomOnlineUsersRequest {
RequestMeta meta = 1;
string room_id = 2;
int64 viewer_user_id = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListRoomOnlineUsersResponse {
repeated RoomUser users = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
int64 server_time_ms = 5;
}
// RoomCommandService
service RoomCommandService {
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
@ -505,6 +544,7 @@ service RoomCommandService {
rpc MicDown(MicDownRequest) returns (MicDownResponse);
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
rpc SetMicMute(SetMicMuteRequest) returns (SetMicMuteResponse);
rpc ApplyRTCEvent(ApplyRTCEventRequest) returns (ApplyRTCEventResponse);
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
@ -529,4 +569,5 @@ service RoomQueryService {
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/room/v1/room.proto
package roomv1
@ -29,6 +29,7 @@ const (
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
RoomCommandService_SetMicMute_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicMute"
RoomCommandService_ApplyRTCEvent_FullMethodName = "/hyapp.room.v1.RoomCommandService/ApplyRTCEvent"
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
@ -56,6 +57,7 @@ type RoomCommandServiceClient interface {
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, error)
SetMicMute(ctx context.Context, in *SetMicMuteRequest, opts ...grpc.CallOption) (*SetMicMuteResponse, error)
ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
@ -175,6 +177,16 @@ func (c *roomCommandServiceClient) ConfirmMicPublishing(ctx context.Context, in
return out, nil
}
func (c *roomCommandServiceClient) SetMicMute(ctx context.Context, in *SetMicMuteRequest, opts ...grpc.CallOption) (*SetMicMuteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetMicMuteResponse)
err := c.cc.Invoke(ctx, RoomCommandService_SetMicMute_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) ApplyRTCEvent(ctx context.Context, in *ApplyRTCEventRequest, opts ...grpc.CallOption) (*ApplyRTCEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyRTCEventResponse)
@ -281,6 +293,7 @@ type RoomCommandServiceServer interface {
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
SetMicMute(context.Context, *SetMicMuteRequest) (*SetMicMuteResponse, error)
ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error)
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
@ -330,6 +343,9 @@ func (UnimplementedRoomCommandServiceServer) ChangeMicSeat(context.Context, *Cha
func (UnimplementedRoomCommandServiceServer) ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConfirmMicPublishing not implemented")
}
func (UnimplementedRoomCommandServiceServer) SetMicMute(context.Context, *SetMicMuteRequest) (*SetMicMuteResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetMicMute not implemented")
}
func (UnimplementedRoomCommandServiceServer) ApplyRTCEvent(context.Context, *ApplyRTCEventRequest) (*ApplyRTCEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyRTCEvent not implemented")
}
@ -558,6 +574,24 @@ func _RoomCommandService_ConfirmMicPublishing_Handler(srv interface{}, ctx conte
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_SetMicMute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetMicMuteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).SetMicMute(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_SetMicMute_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).SetMicMute(ctx, req.(*SetMicMuteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_ApplyRTCEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyRTCEventRequest)
if err := dec(in); err != nil {
@ -767,6 +801,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ConfirmMicPublishing",
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
},
{
MethodName: "SetMicMute",
Handler: _RoomCommandService_SetMicMute_Handler,
},
{
MethodName: "ApplyRTCEvent",
Handler: _RoomCommandService_ApplyRTCEvent_Handler,
@ -953,11 +991,12 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
}
const (
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
)
// RoomQueryServiceClient is the client API for RoomQueryService service.
@ -971,6 +1010,7 @@ type RoomQueryServiceClient interface {
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error)
}
type roomQueryServiceClient struct {
@ -1031,6 +1071,16 @@ func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoo
return out, nil
}
func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomOnlineUsersResponse)
err := c.cc.Invoke(ctx, RoomQueryService_ListRoomOnlineUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomQueryServiceServer is the server API for RoomQueryService service.
// All implementations must embed UnimplementedRoomQueryServiceServer
// for forward compatibility.
@ -1042,6 +1092,7 @@ type RoomQueryServiceServer interface {
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error)
mustEmbedUnimplementedRoomQueryServiceServer()
}
@ -1067,6 +1118,9 @@ func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetC
func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRoomSnapshot not implemented")
}
func (UnimplementedRoomQueryServiceServer) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoomOnlineUsers not implemented")
}
func (UnimplementedRoomQueryServiceServer) mustEmbedUnimplementedRoomQueryServiceServer() {}
func (UnimplementedRoomQueryServiceServer) testEmbeddedByValue() {}
@ -1178,6 +1232,24 @@ func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_ListRoomOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomOnlineUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).ListRoomOnlineUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_ListRoomOnlineUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).ListRoomOnlineUsers(ctx, req.(*ListRoomOnlineUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomQueryService_ServiceDesc is the grpc.ServiceDesc for RoomQueryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1205,6 +1277,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetRoomSnapshot",
Handler: _RoomQueryService_GetRoomSnapshot_Handler,
},
{
MethodName: "ListRoomOnlineUsers",
Handler: _RoomQueryService_ListRoomOnlineUsers_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/user/v1/user.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/user/v1/user.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v5.27.3
// protoc v5.29.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -7361,6 +7361,202 @@ func (x *CreditTaskRewardResponse) GetGrantedAtMs() int64 {
return 0
}
// ApplyGameCoinChangeRequest 是 game-service 唯一可以调用的钱包游戏改账入口。
type ApplyGameCoinChangeRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
CommandId string `protobuf:"bytes,3,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
UserId int64 `protobuf:"varint,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
PlatformCode string `protobuf:"bytes,5,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"`
GameId string `protobuf:"bytes,6,opt,name=game_id,json=gameId,proto3" json:"game_id,omitempty"`
ProviderOrderId string `protobuf:"bytes,7,opt,name=provider_order_id,json=providerOrderId,proto3" json:"provider_order_id,omitempty"`
ProviderRoundId string `protobuf:"bytes,8,opt,name=provider_round_id,json=providerRoundId,proto3" json:"provider_round_id,omitempty"`
OpType string `protobuf:"bytes,9,opt,name=op_type,json=opType,proto3" json:"op_type,omitempty"`
CoinAmount int64 `protobuf:"varint,10,opt,name=coin_amount,json=coinAmount,proto3" json:"coin_amount,omitempty"`
RoomId string `protobuf:"bytes,11,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
RequestHash string `protobuf:"bytes,12,opt,name=request_hash,json=requestHash,proto3" json:"request_hash,omitempty"`
}
func (x *ApplyGameCoinChangeRequest) Reset() {
*x = ApplyGameCoinChangeRequest{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyGameCoinChangeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyGameCoinChangeRequest) ProtoMessage() {}
func (x *ApplyGameCoinChangeRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[82]
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 ApplyGameCoinChangeRequest.ProtoReflect.Descriptor instead.
func (*ApplyGameCoinChangeRequest) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{82}
}
func (x *ApplyGameCoinChangeRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetAppCode() string {
if x != nil {
return x.AppCode
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *ApplyGameCoinChangeRequest) GetPlatformCode() string {
if x != nil {
return x.PlatformCode
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetGameId() string {
if x != nil {
return x.GameId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetProviderOrderId() string {
if x != nil {
return x.ProviderOrderId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetProviderRoundId() string {
if x != nil {
return x.ProviderRoundId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetOpType() string {
if x != nil {
return x.OpType
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetCoinAmount() int64 {
if x != nil {
return x.CoinAmount
}
return 0
}
func (x *ApplyGameCoinChangeRequest) GetRoomId() string {
if x != nil {
return x.RoomId
}
return ""
}
func (x *ApplyGameCoinChangeRequest) GetRequestHash() string {
if x != nil {
return x.RequestHash
}
return ""
}
// ApplyGameCoinChangeResponse 返回游戏改账流水和用户 COIN 账后余额。
type ApplyGameCoinChangeResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
WalletTransactionId string `protobuf:"bytes,1,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
BalanceAfter int64 `protobuf:"varint,2,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
IdempotentReplay bool `protobuf:"varint,3,opt,name=idempotent_replay,json=idempotentReplay,proto3" json:"idempotent_replay,omitempty"`
}
func (x *ApplyGameCoinChangeResponse) Reset() {
*x = ApplyGameCoinChangeResponse{}
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyGameCoinChangeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyGameCoinChangeResponse) ProtoMessage() {}
func (x *ApplyGameCoinChangeResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_wallet_v1_wallet_proto_msgTypes[83]
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 ApplyGameCoinChangeResponse.ProtoReflect.Descriptor instead.
func (*ApplyGameCoinChangeResponse) Descriptor() ([]byte, []int) {
return file_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{83}
}
func (x *ApplyGameCoinChangeResponse) GetWalletTransactionId() string {
if x != nil {
return x.WalletTransactionId
}
return ""
}
func (x *ApplyGameCoinChangeResponse) GetBalanceAfter() int64 {
if x != nil {
return x.BalanceAfter
}
return 0
}
func (x *ApplyGameCoinChangeResponse) GetIdempotentReplay() bool {
if x != nil {
return x.IdempotentReplay
}
return false
}
var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
var file_proto_wallet_v1_wallet_proto_rawDesc = []byte{
@ -8673,241 +8869,285 @@ var file_proto_wallet_v1_wallet_proto_rawDesc = []byte{
0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20,
0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0d, 0x67,
0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x32,
0xe9, 0x1c, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x12, 0x52, 0x0a, 0x09, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x21,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61,
0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63,
0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42,
0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x67, 0x0a, 0x10, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73,
0x73, 0x65, 0x74, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69,
0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x85, 0x01, 0x0a, 0x1a, 0x41, 0x64, 0x6d,
0x28, 0x03, 0x52, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22,
0x9a, 0x03, 0x0a, 0x1a, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x47, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x69,
0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d,
0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a,
0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d,
0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f,
0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x23, 0x0a, 0x0d, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x5f, 0x63, 0x6f, 0x64,
0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72,
0x6d, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x61, 0x6d, 0x65, 0x5f, 0x69, 0x64,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x61, 0x6d, 0x65, 0x49, 0x64, 0x12, 0x2a,
0x0a, 0x11, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x6f, 0x76, 0x69,
0x64, 0x65, 0x72, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x70, 0x72,
0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18,
0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52,
0x6f, 0x75, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x6f, 0x70, 0x5f, 0x74, 0x79, 0x70,
0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x70, 0x54, 0x79, 0x70, 0x65, 0x12,
0x1f, 0x0a, 0x0b, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0a,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74,
0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0b, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x61, 0x73, 0x68, 0x22, 0xa3, 0x01, 0x0a,
0x1b, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x47, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x69, 0x6e, 0x43, 0x68,
0x61, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x15,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64,
0x12, 0x23, 0x0a, 0x0d, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x61, 0x66, 0x74, 0x65,
0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74,
0x65, 0x6e, 0x74, 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08,
0x52, 0x10, 0x69, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x70, 0x6c,
0x61, 0x79, 0x32, 0xdb, 0x1d, 0x0a, 0x0d, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72,
0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x09, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66,
0x74, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42,
0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c,
0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47,
0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69,
0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72,
0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73,
0x73, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x85, 0x01, 0x0a, 0x1a,
0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53,
0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d,
0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c,
0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43,
0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53,
0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64,
0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x53, 0x65, 0x6c,
0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x79, 0x0a, 0x16, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e,
0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c,
0x6c, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x72, 0x61,
0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c,
0x6c, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d, 0x4c,
0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x25, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x47,
0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x61, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74,
0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x67, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13, 0x43, 0x72,
0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26,
0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x2b, 0x2e,
0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x43, 0x6f, 0x69, 0x6e,
0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x53, 0x74, 0x6f, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a, 0x16, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43,
0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x12, 0x2e, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72,
0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x70, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53,
0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a, 0x10, 0x43, 0x72,
0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x28,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a,
0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d,
0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x43, 0x6f, 0x69, 0x6e, 0x46, 0x72, 0x6f, 0x6d,
0x53, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e,
0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12,
0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58,
0x0a, 0x0b, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x23, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61,
0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65,
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x0e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x61, 0x0a, 0x11, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69,
0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a,
0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x6f, 0x75, 0x70, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75,
0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x13, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70,
0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x47,
0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x61, 0x0a,
0x10, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x67, 0x0a, 0x13, 0x53, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x47, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d, 0x47, 0x72, 0x61,
0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x72, 0x61,
0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e,
0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x12, 0x47, 0x72, 0x61,
0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12,
0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55,
0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x6a, 0x0a, 0x11, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f,
0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72,
0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d, 0x0a, 0x12, 0x4c,
0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74,
0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e,
0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x4c, 0x69,
0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x12,
0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69,
0x6c, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x12, 0x29, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65,
0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x76, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x56,
0x61, 0x6c, 0x75, 0x65, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x2d, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65,
0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x75, 0x6d, 0x6d,
0x61, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74,
0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x75, 0x6d, 0x6d, 0x61,
0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x73, 0x0a, 0x14, 0x4c, 0x69,
0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63,
0x74, 0x73, 0x12, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67,
0x65, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x2d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x50,
0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x7f, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64, 0x45, 0x78, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x30, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65,
0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x47, 0x65, 0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x79, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54, 0x72,
0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73,
0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69,
0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x0f, 0x41,
0x70, 0x70, 0x6c, 0x79, 0x57, 0x69, 0x74, 0x68, 0x64, 0x72, 0x61, 0x77, 0x61, 0x6c, 0x12, 0x27,
0x12, 0x61, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x47, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x57, 0x69, 0x74, 0x68, 0x64, 0x72, 0x61, 0x77, 0x61, 0x6c,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x57,
0x69, 0x74, 0x68, 0x64, 0x72, 0x61, 0x77, 0x61, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x64, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x69, 0x70, 0x50, 0x61, 0x63, 0x6b,
0x61, 0x67, 0x65, 0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x69, 0x70, 0x50, 0x61,
0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e,
0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x13, 0x53, 0x65, 0x74, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74,
0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5e, 0x0a, 0x0d,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x25, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x56, 0x69, 0x70, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x4d, 0x79,
0x56, 0x69, 0x70, 0x12, 0x20, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x79, 0x56, 0x69, 0x70, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x79, 0x56, 0x69, 0x70,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x50, 0x75, 0x72, 0x63,
0x68, 0x61, 0x73, 0x65, 0x56, 0x69, 0x70, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x75, 0x72, 0x63, 0x68, 0x61,
0x73, 0x65, 0x56, 0x69, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50,
0x75, 0x72, 0x63, 0x68, 0x61, 0x73, 0x65, 0x56, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54,
0x61, 0x73, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x77,
0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x2a, 0x5a, 0x28, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x68, 0x0a, 0x12,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f,
0x75, 0x70, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73,
0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69,
0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65,
0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55,
0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x71, 0x75, 0x69, 0x70, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65,
0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6d,
0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72,
0x61, 0x6e, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75,
0x72, 0x63, 0x65, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a,
0x11, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c,
0x6c, 0x73, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67,
0x65, 0x42, 0x69, 0x6c, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x42, 0x69, 0x6c, 0x6c,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x11, 0x47, 0x65, 0x74,
0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x12, 0x29,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69,
0x65, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x57,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x4f, 0x76, 0x65, 0x72, 0x76, 0x69, 0x65, 0x77, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x76, 0x0a, 0x15, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x2d,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53,
0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x47, 0x65, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x53, 0x75,
0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x73, 0x0a,
0x14, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x50, 0x72, 0x6f,
0x64, 0x75, 0x63, 0x74, 0x73, 0x12, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68,
0x61, 0x72, 0x67, 0x65, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x63, 0x68, 0x61, 0x72,
0x67, 0x65, 0x50, 0x72, 0x6f, 0x64, 0x75, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x7f, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64,
0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x30,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x47, 0x65, 0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64, 0x45, 0x78, 0x63, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x31, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x44, 0x69, 0x61, 0x6d, 0x6f, 0x6e, 0x64, 0x45, 0x78, 0x63,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2e, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61,
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x4c, 0x69, 0x73, 0x74, 0x57, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61,
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64,
0x0a, 0x0f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x57, 0x69, 0x74, 0x68, 0x64, 0x72, 0x61, 0x77, 0x61,
0x6c, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x57, 0x69, 0x74, 0x68, 0x64, 0x72, 0x61,
0x77, 0x61, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70,
0x6c, 0x79, 0x57, 0x69, 0x74, 0x68, 0x64, 0x72, 0x61, 0x77, 0x61, 0x6c, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x64, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x69, 0x70, 0x50,
0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x12, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x69,
0x70, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x56, 0x69, 0x70, 0x50, 0x61, 0x63, 0x6b, 0x61, 0x67,
0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x08, 0x47, 0x65,
0x74, 0x4d, 0x79, 0x56, 0x69, 0x70, 0x12, 0x20, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77,
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x79, 0x56, 0x69,
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x79,
0x56, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x0b, 0x50,
0x75, 0x72, 0x63, 0x68, 0x61, 0x73, 0x65, 0x56, 0x69, 0x70, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x75, 0x72,
0x63, 0x68, 0x61, 0x73, 0x65, 0x56, 0x69, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x50, 0x75, 0x72, 0x63, 0x68, 0x61, 0x73, 0x65, 0x56, 0x69, 0x70, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54,
0x61, 0x73, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x64,
0x69, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x61, 0x73, 0x6b,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70,
0x0a, 0x13, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x47, 0x61, 0x6d, 0x65, 0x43, 0x6f, 0x69, 0x6e, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61,
0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x47, 0x61, 0x6d,
0x65, 0x43, 0x6f, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x47, 0x61, 0x6d, 0x65, 0x43, 0x6f,
0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x42, 0x2a, 0x5a, 0x28, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f,
0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x2f, 0x76, 0x31, 0x3b, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -8922,7 +9162,7 @@ func file_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte {
return file_proto_wallet_v1_wallet_proto_rawDescData
}
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 82)
var file_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 84)
var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
@ -9006,6 +9246,8 @@ var file_proto_wallet_v1_wallet_proto_goTypes = []any{
(*PurchaseVipResponse)(nil), // 79: hyapp.wallet.v1.PurchaseVipResponse
(*CreditTaskRewardRequest)(nil), // 80: hyapp.wallet.v1.CreditTaskRewardRequest
(*CreditTaskRewardResponse)(nil), // 81: hyapp.wallet.v1.CreditTaskRewardResponse
(*ApplyGameCoinChangeRequest)(nil), // 82: hyapp.wallet.v1.ApplyGameCoinChangeRequest
(*ApplyGameCoinChangeResponse)(nil), // 83: hyapp.wallet.v1.ApplyGameCoinChangeResponse
}
var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
@ -9080,43 +9322,45 @@ var file_proto_wallet_v1_wallet_proto_depIdxs = []int32{
76, // 69: hyapp.wallet.v1.WalletService.GetMyVip:input_type -> hyapp.wallet.v1.GetMyVipRequest
78, // 70: hyapp.wallet.v1.WalletService.PurchaseVip:input_type -> hyapp.wallet.v1.PurchaseVipRequest
80, // 71: hyapp.wallet.v1.WalletService.CreditTaskReward:input_type -> hyapp.wallet.v1.CreditTaskRewardRequest
1, // 72: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 73: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 74: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 75: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 76: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
20, // 77: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
22, // 78: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
26, // 79: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 80: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 81: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 82: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
30, // 83: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
34, // 84: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 85: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 86: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 87: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 88: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 89: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 90: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
43, // 91: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
43, // 92: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
45, // 93: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
47, // 94: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
49, // 95: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
52, // 96: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
55, // 97: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
58, // 98: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
61, // 99: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
64, // 100: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
67, // 101: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
70, // 102: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
75, // 103: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
77, // 104: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
79, // 105: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
81, // 106: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
72, // [72:107] is the sub-list for method output_type
37, // [37:72] is the sub-list for method input_type
82, // 72: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:input_type -> hyapp.wallet.v1.ApplyGameCoinChangeRequest
1, // 73: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
4, // 74: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
6, // 75: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
8, // 76: hyapp.wallet.v1.WalletService.AdminCreditCoinSellerStock:output_type -> hyapp.wallet.v1.AdminCreditCoinSellerStockResponse
10, // 77: hyapp.wallet.v1.WalletService.TransferCoinFromSeller:output_type -> hyapp.wallet.v1.TransferCoinFromSellerResponse
20, // 78: hyapp.wallet.v1.WalletService.ListResources:output_type -> hyapp.wallet.v1.ListResourcesResponse
22, // 79: hyapp.wallet.v1.WalletService.GetResource:output_type -> hyapp.wallet.v1.GetResourceResponse
26, // 80: hyapp.wallet.v1.WalletService.CreateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 81: hyapp.wallet.v1.WalletService.UpdateResource:output_type -> hyapp.wallet.v1.ResourceResponse
26, // 82: hyapp.wallet.v1.WalletService.SetResourceStatus:output_type -> hyapp.wallet.v1.ResourceResponse
28, // 83: hyapp.wallet.v1.WalletService.ListResourceGroups:output_type -> hyapp.wallet.v1.ListResourceGroupsResponse
30, // 84: hyapp.wallet.v1.WalletService.GetResourceGroup:output_type -> hyapp.wallet.v1.GetResourceGroupResponse
34, // 85: hyapp.wallet.v1.WalletService.CreateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 86: hyapp.wallet.v1.WalletService.UpdateResourceGroup:output_type -> hyapp.wallet.v1.ResourceGroupResponse
34, // 87: hyapp.wallet.v1.WalletService.SetResourceGroupStatus:output_type -> hyapp.wallet.v1.ResourceGroupResponse
36, // 88: hyapp.wallet.v1.WalletService.ListGiftConfigs:output_type -> hyapp.wallet.v1.ListGiftConfigsResponse
40, // 89: hyapp.wallet.v1.WalletService.CreateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 90: hyapp.wallet.v1.WalletService.UpdateGiftConfig:output_type -> hyapp.wallet.v1.GiftConfigResponse
40, // 91: hyapp.wallet.v1.WalletService.SetGiftConfigStatus:output_type -> hyapp.wallet.v1.GiftConfigResponse
43, // 92: hyapp.wallet.v1.WalletService.GrantResource:output_type -> hyapp.wallet.v1.ResourceGrantResponse
43, // 93: hyapp.wallet.v1.WalletService.GrantResourceGroup:output_type -> hyapp.wallet.v1.ResourceGrantResponse
45, // 94: hyapp.wallet.v1.WalletService.ListUserResources:output_type -> hyapp.wallet.v1.ListUserResourcesResponse
47, // 95: hyapp.wallet.v1.WalletService.EquipUserResource:output_type -> hyapp.wallet.v1.EquipUserResourceResponse
49, // 96: hyapp.wallet.v1.WalletService.ListResourceGrants:output_type -> hyapp.wallet.v1.ListResourceGrantsResponse
52, // 97: hyapp.wallet.v1.WalletService.ListRechargeBills:output_type -> hyapp.wallet.v1.ListRechargeBillsResponse
55, // 98: hyapp.wallet.v1.WalletService.GetWalletOverview:output_type -> hyapp.wallet.v1.GetWalletOverviewResponse
58, // 99: hyapp.wallet.v1.WalletService.GetWalletValueSummary:output_type -> hyapp.wallet.v1.GetWalletValueSummaryResponse
61, // 100: hyapp.wallet.v1.WalletService.ListRechargeProducts:output_type -> hyapp.wallet.v1.ListRechargeProductsResponse
64, // 101: hyapp.wallet.v1.WalletService.GetDiamondExchangeConfig:output_type -> hyapp.wallet.v1.GetDiamondExchangeConfigResponse
67, // 102: hyapp.wallet.v1.WalletService.ListWalletTransactions:output_type -> hyapp.wallet.v1.ListWalletTransactionsResponse
70, // 103: hyapp.wallet.v1.WalletService.ApplyWithdrawal:output_type -> hyapp.wallet.v1.ApplyWithdrawalResponse
75, // 104: hyapp.wallet.v1.WalletService.ListVipPackages:output_type -> hyapp.wallet.v1.ListVipPackagesResponse
77, // 105: hyapp.wallet.v1.WalletService.GetMyVip:output_type -> hyapp.wallet.v1.GetMyVipResponse
79, // 106: hyapp.wallet.v1.WalletService.PurchaseVip:output_type -> hyapp.wallet.v1.PurchaseVipResponse
81, // 107: hyapp.wallet.v1.WalletService.CreditTaskReward:output_type -> hyapp.wallet.v1.CreditTaskRewardResponse
83, // 108: hyapp.wallet.v1.WalletService.ApplyGameCoinChange:output_type -> hyapp.wallet.v1.ApplyGameCoinChangeResponse
73, // [73:109] is the sub-list for method output_type
37, // [37:73] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
37, // [37:37] is the sub-list for extension extendee
0, // [0:37] is the sub-list for field type_name
@ -9135,7 +9379,7 @@ func file_proto_wallet_v1_wallet_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_wallet_v1_wallet_proto_rawDesc,
NumEnums: 0,
NumMessages: 82,
NumMessages: 84,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -805,6 +805,29 @@ message CreditTaskRewardResponse {
int64 granted_at_ms = 4;
}
// ApplyGameCoinChangeRequest game-service
message ApplyGameCoinChangeRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
int64 user_id = 4;
string platform_code = 5;
string game_id = 6;
string provider_order_id = 7;
string provider_round_id = 8;
string op_type = 9;
int64 coin_amount = 10;
string room_id = 11;
string request_hash = 12;
}
// ApplyGameCoinChangeResponse COIN
message ApplyGameCoinChangeResponse {
string wallet_transaction_id = 1;
int64 balance_after = 2;
bool idempotent_replay = 3;
}
// WalletService gRPC HTTP gateway-service
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
@ -842,4 +865,5 @@ service WalletService {
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
rpc ApplyGameCoinChange(ApplyGameCoinChangeRequest) returns (ApplyGameCoinChangeResponse);
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// - protoc v5.29.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -54,6 +54,7 @@ const (
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
WalletService_ApplyGameCoinChange_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyGameCoinChange"
)
// WalletServiceClient is the client API for WalletService service.
@ -97,6 +98,7 @@ type WalletServiceClient interface {
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error)
ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error)
}
type walletServiceClient struct {
@ -457,6 +459,16 @@ func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTa
return out, nil
}
func (c *walletServiceClient) ApplyGameCoinChange(ctx context.Context, in *ApplyGameCoinChangeRequest, opts ...grpc.CallOption) (*ApplyGameCoinChangeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyGameCoinChangeResponse)
err := c.cc.Invoke(ctx, WalletService_ApplyGameCoinChange_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// WalletServiceServer is the server API for WalletService service.
// All implementations must embed UnimplementedWalletServiceServer
// for forward compatibility.
@ -498,6 +510,7 @@ type WalletServiceServer interface {
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
@ -613,6 +626,9 @@ func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVi
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditTaskReward not implemented")
}
func (UnimplementedWalletServiceServer) ApplyGameCoinChange(context.Context, *ApplyGameCoinChangeRequest) (*ApplyGameCoinChangeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyGameCoinChange not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
@ -1264,6 +1280,24 @@ func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyGameCoinChange_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyGameCoinChangeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ApplyGameCoinChange(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ApplyGameCoinChange_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ApplyGameCoinChange(ctx, req.(*ApplyGameCoinChangeRequest))
}
return interceptor(ctx, in, info, handler)
}
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1411,6 +1445,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,
},
{
MethodName: "ApplyGameCoinChange",
Handler: _WalletService_ApplyGameCoinChange_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/wallet/v1/wallet.proto",

View File

@ -8,5 +8,6 @@ GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_admin.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_cron.* TO 'hyapp'@'%';
GRANT ALL PRIVILEGES ON hyapp_game.* TO 'hyapp'@'%';
FLUSH PRIVILEGES;

View File

@ -18,6 +18,8 @@ services:
condition: service_healthy
user-service:
condition: service_healthy
game-service:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
@ -141,6 +143,30 @@ services:
retries: 20
start_period: 10s
game-service:
build:
context: .
dockerfile: services/game-service/Dockerfile
args: *go-build-args
container_name: game-service
environment:
TZ: UTC
ports:
- "13008:13008"
depends_on:
mysql:
condition: service_healthy
wallet-service:
condition: service_healthy
user-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
mysql:
image: mysql:8.4
container_name: hyapp-mysql
@ -166,6 +192,7 @@ services:
- ./deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
- ./deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/008_cron_service.sql:ro
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/009_game_service.sql:ro
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]

View File

@ -21,6 +21,9 @@
| GET | `/api/v1/im/usersig` | tencent-im | `issueTencentIMUserSig` | 获取腾讯云 IM UserSig |
| POST | `/api/v1/rtc/token` | tencent-rtc | `issueTencentRTCToken` | 获取腾讯 RTC 进房 UserSig |
| POST | `/api/v1/tencent-rtc/callback` | tencent-rtc | `handleTencentRTCCallback` | 腾讯 RTC 服务端事件回调 |
| GET | `/api/v1/games` | games | `listGames` | 获取语音房合并游戏列表 |
| POST | `/api/v1/games/{game_id}/launch` | games | `launchGame` | 创建 H5 游戏启动会话 |
| POST | `/api/v1/game-callbacks/{platform_code}/{operation}` | games | `handleGameCallback` | 第三方游戏平台公网回调 |
| POST | `/api/v1/files/upload` | files | `uploadFile` | 上传 App 通用文件 |
| POST | `/api/v1/files/avatar/upload` | files | `uploadAvatar` | 上传用户头像 |
| POST | `/api/v1/devices/push-token` | devices | `bindPushToken` | 绑定设备推送 token |
@ -28,6 +31,7 @@
| POST | `/api/v1/tencent-im/callback` | tencent-im | `handleTencentIMCallback` | 腾讯云 IM 服务端回调 |
| GET | `/api/v1/users/by-display-user-id/{display_user_id}` | users | `resolveDisplayUserID` | 通过当前展示短号解析用户 |
| GET | `/api/v1/users/profiles:batch` | users | `batchUserProfiles` | 批量查询会话展示资料 |
| GET | `/api/v1/users/room-display-profiles:batch` | users | `batchRoomDisplayProfiles` | 批量查询房间和公屏展示资料 |
| GET | `/api/v1/users/me/identity` | users | `getMyIdentity` | 查询当前用户展示短号状态 |
| GET | `/api/v1/users/me/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |
@ -52,15 +56,20 @@
| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following 房间流 |
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
| GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 |
| GET | `/api/v1/rooms/{room_id}/detail` | rooms | `getRoomDetail` | 查询房间详情页首屏聚合包 |
| GET | `/api/v1/rooms/{room_id}/online-users` | rooms | `listRoomOnlineUsers` | 分页查询房间在线用户 |
| GET | `/api/v1/rooms/{room_id}/gift-panel` | rooms | `getRoomGiftPanel` | 查询房间礼物面板初始化数据 |
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
| POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 |
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token |
| POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence |
| POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 |
| POST | `/api/v1/rooms/close` | rooms | `closeRoom` | 设计稿电源按钮:离开房间但不关闭房间 |
| POST | `/api/v1/rooms/mic/up` | rooms | `micUp` | 上麦 |
| POST | `/api/v1/rooms/mic/down` | rooms | `micDown` | 下麦 |
| POST | `/api/v1/rooms/mic/change` | rooms | `changeMicSeat` | 调整麦位 |
| POST | `/api/v1/rooms/mic/publishing/confirm` | rooms | `confirmMicPublishing` | 确认 RTC 音频发布成功 |
| POST | `/api/v1/rooms/mic/mute` | rooms | `setMicMute` | 设置服务端可见麦克风静音状态 |
| POST | `/api/v1/rooms/mic/lock` | rooms | `setMicSeatLock` | 锁定或解锁麦位 |
| POST | `/api/v1/rooms/chat/enabled` | rooms | `setChatEnabled` | 开启或关闭房间公屏 |
| POST | `/api/v1/rooms/admin/set` | rooms | `setRoomAdmin` | 添加或移除房间管理员 |
@ -133,6 +142,13 @@
| PUT | `/api/v1/admin/gifts/{gift_id}` | admin | `updateGift` | updateGift |
| POST | `/api/v1/admin/gifts/{gift_id}/disable` | admin | `disableGift` | disableGift |
| POST | `/api/v1/admin/gifts/{gift_id}/enable` | admin | `enableGift` | enableGift |
| GET | `/api/v1/admin/game/platforms` | admin | `listGamePlatforms` | 查询游戏平台配置 |
| POST | `/api/v1/admin/game/platforms` | admin | `upsertGamePlatform` | 创建或更新游戏平台 |
| PATCH | `/api/v1/admin/game/platforms/{platform_code}` | admin | `updateGamePlatform` | 更新游戏平台 |
| GET | `/api/v1/admin/game/games` | admin | `listGameCatalog` | 查询游戏目录 |
| POST | `/api/v1/admin/game/games` | admin | `upsertGameCatalog` | 创建或更新游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}` | admin | `updateGameCatalog` | 更新游戏目录 |
| PATCH | `/api/v1/admin/game/games/{game_id}/status` | admin | `setGameStatus` | 设置游戏状态 |
| GET | `/api/v1/admin/hosts` | admin | `listHosts` | listHosts |
| GET | `/api/v1/admin/payment/recharge-bills` | admin | `listRechargeBills` | listRechargeBills |
| GET | `/api/v1/admin/regions` | admin | `listRegions` | listRegions |
@ -286,8 +302,19 @@
| POST | `/grpc.health.v1.Health/Check` | health | `roomHealthCheck` | 标准 gRPC health check |
| POST | `/grpc.health.v1.Health/Watch` | health | `roomHealthWatch` | 标准 gRPC health watch |
| POST | `/hyapp.wallet.v1.WalletService/DebitGift` | wallet | `walletDebitGift` | 送礼扣费 |
| POST | `/hyapp.wallet.v1.WalletService/ApplyGameCoinChange` | wallet | `walletApplyGameCoinChange` | 游戏专用金币改账 |
| POST | `/grpc.health.v1.Health/Check` | health | `walletHealthCheck` | 标准 gRPC health check |
| POST | `/grpc.health.v1.Health/Watch` | health | `walletHealthWatch` | 标准 gRPC health watch |
| POST | `/hyapp.game.v1.GameAppService/ListGames` | games | `gameListGames` | App 合并游戏列表 |
| POST | `/hyapp.game.v1.GameAppService/LaunchGame` | games | `gameLaunchGame` | 创建 H5 启动会话 |
| POST | `/hyapp.game.v1.GameCallbackService/HandleCallback` | games | `gameHandleCallback` | 处理平台原始回调 |
| POST | `/hyapp.game.v1.GameAdminService/ListPlatforms` | games-admin | `gameAdminListPlatforms` | 查询游戏平台配置 |
| POST | `/hyapp.game.v1.GameAdminService/UpsertPlatform` | games-admin | `gameAdminUpsertPlatform` | 创建或更新游戏平台 |
| POST | `/hyapp.game.v1.GameAdminService/ListCatalog` | games-admin | `gameAdminListCatalog` | 查询游戏目录 |
| POST | `/hyapp.game.v1.GameAdminService/UpsertCatalog` | games-admin | `gameAdminUpsertCatalog` | 创建或更新游戏目录 |
| POST | `/hyapp.game.v1.GameAdminService/SetGameStatus` | games-admin | `gameAdminSetGameStatus` | 设置游戏状态 |
| POST | `/grpc.health.v1.Health/Check` | health | `gameHealthCheck` | 标准 gRPC health check |
| POST | `/grpc.health.v1.Health/Watch` | health | `gameHealthWatch` | 标准 gRPC health watch |
| POST | `/hyapp.activity.v1.ActivityService/PingActivity` | activity | `activityPingActivity` | 活动服务连通性探测 |
| POST | `/hyapp.activity.v1.ActivityService/GetActivityStatus` | activity | `activityGetActivityStatus` | 查询活动状态 |
| POST | `/hyapp.activity.v1.MessageInboxService/ListMessageTabs` | messages | `activityListMessageTabs` | 查询 App 消息 tab 摘要 |

574
docs/游戏接入架构.md Normal file
View File

@ -0,0 +1,574 @@
# 游戏接入架构
本文定义语音房 H5 弹窗游戏的服务边界、平台接入、金币改账、App 游戏列表、后台管理和补单对账方案。游戏可以来自百顺、灵仙、即构、小游等多个平台,但 App 侧看到的是一个合并后的游戏列表。
## 目标
- 在语音房内打开 H5 弹窗游戏,支持多个第三方游戏平台并行接入。
- App 获取统一游戏列表,不关心游戏来自哪个平台,但返回字段必须包含 `platform_code` 便于排查和埋点。
- 第三方平台回调可以获取用户信息、查询余额、变更用户金币和补单。
- 后台增加一级菜单 `游戏管理`,二级菜单 `游戏列表`,列表按平台筛选和维护。
- 所有金币变化仍由 `wallet-service` 落账游戏平台、gateway、admin 和 room-service 都不能直接改金币余额。
## 非目标
- 不把游戏状态放进 Room Cell。房间只负责展示入口、房间上下文和必要的 IM 通知。
- 不让第三方平台直接访问 wallet-service 或 user-service。
- 不用 Redis 保存金币、订单或补单事实。Redis 只可用于短期 launch token、限流和幂等热缓存。
- 不把不同平台的协议字段直接扩散到 App、room-service 或 wallet-service。
## 服务边界
新增 `game-service`,端口建议使用 `13008`MySQL 数据库建议使用 `hyapp_game`
| 服务 | 职责 |
|---|---|
| `gateway-service` | App HTTP 入口;游戏列表、启动游戏、第三方平台公网回调入口;鉴权、协议转换、原始请求体透传。 |
| `game-service` | 游戏平台配置、游戏目录、展示规则、H5 启动会话、平台回调验签、平台协议适配、游戏订单、补单和对账任务 owner。 |
| `wallet-service` | 用户 `COIN` 的唯一 owner处理游戏扣款、派奖、退款、补单入账和幂等流水。 |
| `user-service` | 用户基础资料 owner向 game-service 提供平台回调需要的用户资料投影。 |
| `room-service` | 语音房状态 owner只提供 `room_id` 上下文和可选的在房校验,不处理游戏订单或金币。 |
| `server/admin` | 后台 RBAC、菜单、审计和后台 HTTP API通过 game-service gRPC 管理平台和游戏配置。 |
| `cron-service` | 只触发 game-service 的补单/对账批处理,不直接扫游戏表或修改钱包。 |
```mermaid
flowchart LR
App["App voice room"] -->|"GET /api/v1/games"| Gateway["gateway-service"]
App -->|"POST /api/v1/games/{game_id}/launch"| Gateway
Provider["game platform callback"] -->|"provider HTTP callback"| Gateway
Admin["admin frontend"] -->|"admin HTTP"| AdminServer["server/admin"]
Gateway -->|"gRPC"| Game["game-service"]
AdminServer -->|"gRPC"| Game
Game -->|"gRPC ApplyGameCoinChange"| Wallet["wallet-service"]
Game -->|"gRPC user profile"| User["user-service"]
Game -. optional .->|"validate room context"| Room["room-service"]
Game --> GameDB[("hyapp_game")]
Wallet --> WalletDB[("hyapp_wallet")]
```
## 平台适配模型
每个平台实现一个 adapter把第三方协议转换成内部统一命令。
```go
type PlatformAdapter interface {
VerifyCallback(rawBody []byte, headers map[string]string, query map[string]string, secret PlatformSecret) error
ParseOperation(path string, rawBody []byte) (ProviderOperation, error)
BuildResponse(result ProviderResult) ([]byte, string, error)
BuildLaunchURL(session LaunchSession, user GameUser, game GameConfig) (string, error)
}
```
内部统一操作只保留业务语义:
| 内部操作 | 说明 |
|---|---|
| `get_user_info` | 平台获取用户 ID、昵称、头像、余额等启动或运行信息。 |
| `get_balance` | 平台查询用户当前金币余额。 |
| `change_coin` | 平台发起扣款、派奖、退款、冲正等金币变化。 |
| `repair_order` | 平台或后台补单,重放遗漏订单或修复平台异常订单。 |
| `query_order` | 平台查询内部订单处理结果。 |
平台差异只能停留在 adapter 层。进入 game-service 领域层以后,所有金额单位、订单 ID、用户 ID、游戏 ID、状态机和错误码都必须统一。
## 数据模型
### game_platforms
游戏平台配置表。
```sql
game_platforms(
app_code VARCHAR(32) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
platform_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
api_base_url VARCHAR(512) NOT NULL DEFAULT '',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL,
adapter_config JSON NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, platform_code)
);
```
`status` 建议取值:
| 状态 | 语义 |
|---|---|
| `active` | 可展示、可启动、可接收回调。 |
| `maintenance` | App 可展示维护态,不允许启动新会话;已发生订单仍可接收回调。 |
| `disabled` | 不展示、不允许启动;回调只允许查询历史订单或按配置拒绝。 |
### game_catalog
第三方游戏目录表,一条记录对应一个平台下的一个游戏。
```sql
game_catalog(
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
game_name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id),
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
);
```
`game_id` 是内部稳定 IDApp、订单和补单都使用它`provider_game_id` 只在 adapter 和平台请求中使用。
### game_display_rules
控制游戏在 App 内的展示范围。
```sql
game_display_rules(
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
scene VARCHAR(64) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
language VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
visible TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, rule_id),
KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
);
```
`scene` 当前先支持 `voice_room`,后续可以扩展首页、活动页或个人中心入口。`region_id=0` 表示全区域默认规则,具体区域规则优先级高于默认规则。
### game_launch_sessions
启动会话表,绑定用户、房间、游戏和平台短期令牌。
```sql
game_launch_sessions(
app_code VARCHAR(32) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
launch_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, session_id),
KEY idx_game_launch_user(app_code, user_id, created_at_ms),
KEY idx_game_launch_token(app_code, launch_token_hash)
);
```
H5 URL 里只放短期 `session_token` 或 provider 需要的签名参数,不直接暴露钱包能力。会话过期后不允许继续用它拉用户信息,但历史订单仍按 provider order id 幂等处理。
### game_orders
游戏金币变化订单表。
```sql
game_orders(
app_code VARCHAR(32) NOT NULL,
order_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
wallet_balance_after BIGINT NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
failure_code VARCHAR(64) NOT NULL DEFAULT '',
failure_message VARCHAR(256) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, order_id),
UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
);
```
`op_type` 建议取值:
| 类型 | 钱包语义 |
|---|---|
| `debit` | 用户下注或购买局内道具,扣 `COIN`。 |
| `credit` | 用户中奖或平台派奖,加 `COIN`。 |
| `refund` | 扣款后失败退款,加 `COIN`,必须关联原订单。 |
| `reverse` | 平台冲正,按平台规则转成扣款或退款,不允许直接传负数。 |
订单状态机:
```mermaid
stateDiagram-v2
[*] --> received
received --> wallet_applying
wallet_applying --> succeeded
wallet_applying --> failed
wallet_applying --> conflict
succeeded --> refunded
succeeded --> reversed
failed --> repair_pending
repair_pending --> wallet_applying
```
### game_callback_logs
回调日志表用于审计、排错和补偿,不作为金币事实。
```sql
game_callback_logs(
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
request_id VARCHAR(96) NOT NULL DEFAULT '',
provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
response_code VARCHAR(64) NOT NULL DEFAULT '',
response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, callback_id),
KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
);
```
原始请求体如果较大,存对象存储或压缩表;主表只保留 hash 和引用,避免日志表膨胀影响查询。
### game_repair_orders
补单任务表,来源可以是平台回调、后台手动、对账任务。
```sql
game_repair_orders(
app_code VARCHAR(32) NOT NULL,
repair_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
reason VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, repair_id),
UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
);
```
补单只能重放到 `game_orders``wallet-service` 幂等命令,不能直接 `UPDATE wallet_accounts`
## 钱包改账
钱包底层统一表达为“用户金币增加或减少 + 业务类型”,最终都落到 `wallet_transactions``wallet_entries`。RPC 层不要暴露裸的通用 `ChangeCoin(user_id, amount, type)` 给所有业务直接调用,而是按业务场景封装入口;游戏场景固定由 game-service 调 wallet-service 的 `ApplyGameCoinChange`wallet-service 内部再转换成统一账本分录。
game-service 调 wallet-service 新增内部 RPC
```proto
message ApplyGameCoinChangeRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 user_id = 3;
string platform_code = 4;
string game_id = 5;
string provider_order_id = 6;
string provider_round_id = 7;
string op_type = 8;
int64 coin_amount = 9;
string room_id = 10;
string request_hash = 11;
}
message ApplyGameCoinChangeResponse {
string wallet_transaction_id = 1;
int64 balance_after = 2;
bool idempotent_replay = 3;
}
```
`command_id` 固定为:
```text
game:{platform_code}:{provider_order_id}
```
钱包侧要求:
- `coin_amount` 必须是正整数,方向由 `op_type` 决定。
- `op_type` 只表达业务方向,最终统计口径落在 `wallet_transactions.biz_type``wallet_entries.available_delta`
- `app_code + command_id` 唯一幂等,同一命令重复请求返回同一结果。
- 同一 `command_id` 携带不同 `request_hash` 必须返回冲突,不允许二次改账。
- `debit` 余额不足时不写成功交易、不写分录、不写 outbox。
- `credit/refund/reverse` 必须写 `wallet_transactions``wallet_entries``wallet_outbox`
- 游戏账务 `biz_type` 至少包括 `game_debit``game_credit``game_refund``game_reverse`
统一账本落账示例:
| 游戏动作 | `op_type` | `wallet_transactions.biz_type` | `wallet_entries.asset_type` | `wallet_entries.available_delta` |
|---|---|---|---|---:|
| 下注扣金币 | `debit` | `game_debit` | `COIN` | `-coin_amount` |
| 中奖派奖 | `credit` | `game_credit` | `COIN` | `+coin_amount` |
| 失败退款 | `refund` | `game_refund` | `COIN` | `+coin_amount` |
| 平台冲正 | `reverse` | `game_reverse` | `COIN` | 按冲正规则转换 |
用户金币收支统计只查 `wallet_entries`
```sql
SELECT biz_type, SUM(available_delta) AS coin_delta
FROM wallet_entries
WHERE app_code = ?
AND user_id = ?
AND asset_type = 'COIN'
AND created_at_ms >= ?
AND created_at_ms < ?
GROUP BY biz_type;
```
## App 接口
### 获取合并游戏列表
```http
GET /api/v1/games?scene=voice_room&room_id={room_id}
```
返回:
```json
{
"games": [
{
"game_id": "bs_rocket_001",
"platform_code": "baishun",
"name_key": "game.bs_rocket_001.name",
"name": "Rocket",
"icon_url": "https://cdn.example.com/game/rocket.png",
"cover_url": "https://cdn.example.com/game/rocket-cover.png",
"category": "casual",
"launch_mode": "h5_popup",
"orientation": "portrait",
"min_coin": 100,
"enabled": true,
"maintenance": false,
"sort_order": 10
}
],
"server_time_ms": 1778490000000
}
```
列表聚合规则:
- 只返回 `game_platforms.status in active/maintenance``game_catalog.status in active/maintenance` 的游戏。
- `maintenance=true` 时可以展示但不能启动。
- 根据用户区域、语言、平台和 `scene` 过滤 `game_display_rules`
- 返回是多个平台合并排序,不按平台分组;后台才按平台筛选。
### 启动游戏
```http
POST /api/v1/games/{game_id}/launch
```
请求:
```json
{
"scene": "voice_room",
"room_id": "room_123"
}
```
返回:
```json
{
"session_id": "game_sess_1778490000_abcd",
"launch_url": "https://provider.example.com/h5?...",
"expires_at_ms": 1778490900000,
"orientation": "portrait"
}
```
启动时校验:
- 用户必须已登录且 onboarding completed。
- 游戏和平台不能是 `disabled`
- `maintenance` 状态拒绝启动。
- 如果 `scene=voice_room` 且产品要求必须在房间内game-service 通过 room-service 做只读校验;校验失败不创建启动会话。
## 第三方平台回调
公网入口统一放 gateway-service
```http
POST /api/v1/game-callbacks/{platform_code}/{operation}
```
gateway 行为:
- 保留原始 body、query、header不提前解析成统一 JSON。
- 生成或透传 `request_id`
- 按 `platform_code` 转发给 game-service。
- 回调响应不套 App 业务 envelope因为第三方通常要求固定响应格式。
game-service 行为:
1. 读取平台配置和 adapter。
2. 验签、IP 白名单、时间戳/nonce 防重放。
3. 写 `game_callback_logs`
4. adapter 解析为内部 `ProviderOperation`
5. 需要用户资料时读 user-service需要余额或改账时读/写 wallet-service。
6. 写 `game_orders``game_repair_orders`
7. adapter 构造平台要求的响应。
## 补单和对账
补单来源:
- 平台主动补单回调。
- 后台管理员按 `platform_code + provider_order_id` 手动补单。
- cron-service 触发 game-service 扫描 `game_repair_orders`
- 后续平台支持订单查询时,由 game-service 拉取平台订单对账。
补单约束:
- 补单必须复用原始 `provider_order_id` 生成同一个钱包 `command_id`
- 钱包幂等命中时视为补单成功,但必须记录 `idempotent_replay=true`
- 原订单请求 hash 不一致时进入 `conflict`,需要人工处理。
- 后台手动补单必须写 admin 审计,包含操作人、原因和平台订单号。
## 后台管理
新增一级菜单:
```text
游戏管理
└── 游戏列表
```
游戏列表默认按平台 tab 或筛选展示:
| 字段 | 说明 |
|---|---|
| 平台 | 百顺、灵仙、即构、小游等 `platform_code/platform_name`。 |
| 游戏 ID | 内部 `game_id` 和平台 `provider_game_id`。 |
| 名称/图标 | App 展示资源。 |
| 类目 | casual、slot、board 等运营类目。 |
| 状态 | active、maintenance、disabled。 |
| 展示范围 | scene、区域、语言、端。 |
| 最低金币 | App 展示和启动前提示用。 |
| 排序 | 合并列表排序。 |
| 回调状态 | 最近回调成功率、最近错误码。 |
后台接口由 `server/admin` 提供:
```http
GET /api/v1/admin/game/platforms
POST /api/v1/admin/game/platforms
PATCH /api/v1/admin/game/platforms/{platform_code}
GET /api/v1/admin/game/games?platform_code=baishun
POST /api/v1/admin/game/games
PATCH /api/v1/admin/game/games/{game_id}
PATCH /api/v1/admin/game/games/{game_id}/status
GET /api/v1/admin/game/orders
GET /api/v1/admin/game/callback-logs
POST /api/v1/admin/game/repair-orders
```
`server/admin` 只做权限、审计和后台协议;平台配置和游戏目录事实仍写入 game-service。
当前第一期已落地平台配置和游戏目录维护接口,订单查询、回调日志查询和手动补单入口随补单状态机一起实现。
## 并发与可靠性
- game-service 可以多实例无状态部署;订单事实在 MySQL短期 session 可同时写 MySQL 和 Redis。
- 同一用户多游戏并发改金币时,由 wallet-service 对 `wallet_accounts(app_code,user_id,COIN)` 行锁和命令幂等保证余额正确。
- 平台重复回调必须返回幂等结果,不允许重复扣款或重复派奖。
- 平台回调乱序时game-service 只按 provider order id 和 round id 记录事实;是否允许先派奖后扣款由平台 adapter 配置决定。
- game_callback_logs 不能作为成功事实;成功事实是 `game_orders.status=succeeded` 加 wallet 成功交易。
- game-service outbox 只发布游戏订单完成、补单完成等事件;钱包余额变化仍以 wallet outbox 为准。
## 安全要求
- 每个平台必须独立 secret支持轮换后台只能看到 masked secret。
- 回调验签使用原始 body不使用重新序列化后的 JSON。
- 支持 IP 白名单,但 IP 白名单失败不能替代签名校验。
- 请求时间戳和 nonce 防重放nonce 可短期放 Redis最终仍以 provider order id 幂等兜底。
- H5 启动 token 短有效期,绑定 `app_code/user_id/game_id/platform_code/room_id`
- 所有 provider 回调、补单、后台状态变更必须写审计日志。
- 金币金额使用整数,不允许 float不允许负数金额表达方向。
## 监控指标
| 指标 | 说明 |
|---|---|
| `game_callback_total{platform,operation,status}` | 平台回调量和状态。 |
| `game_callback_verify_failed_total{platform}` | 验签失败和白名单失败。 |
| `game_order_total{platform,op_type,status}` | 游戏订单状态。 |
| `game_wallet_apply_duration_ms{platform,op_type}` | 调钱包耗时。 |
| `game_wallet_conflict_total{platform}` | 幂等冲突数量。 |
| `game_repair_pending_total{platform}` | 待补单数量。 |
| `game_launch_total{platform,game_id,status}` | 游戏启动量和失败原因。 |
## 落地顺序
1. 新增 `game-service` 空服务、`hyapp_game` schema、配置和 docker-compose 端口 `13008`
2. 定义 `api/proto/game/v1/game.proto`,包含 App 列表、启动、后台管理和回调处理 RPC。
3. wallet-service 增加 `ApplyGameCoinChange` RPC 和 `game_*` 账务类型测试。
4. game-service 实现平台配置、游戏目录、展示规则和 App 合并列表。
5. game-service 实现 H5 launch sessiongateway 暴露 `/api/v1/games``/api/v1/games/{game_id}/launch`
6. 实现第一个平台 adapter先打通 `get_user_info/get_balance/change_coin`
7. 增加 `game_orders``game_callback_logs`、补单幂等和冲突处理。
8. server/admin 增加 `游戏管理 / 游戏列表`,接入 game-service 管理接口。
9. 增加补单后台入口和 cron-service 触发的 game-service 补偿批处理。
10. 接入第二个平台前,固化 adapter 测试基线,避免平台差异污染核心领域层。
## 验收清单
- App 能在语音房拿到多个平台合并后的游戏列表。
- 禁用平台、维护平台、禁用游戏、区域不匹配游戏不会被错误启动。
- 启动游戏返回短期 H5 URL过期 session 不能再换取用户信息。
- 平台扣金币成功后 wallet 余额减少,重复回调不重复扣。
- 平台派奖成功后 wallet 余额增加,重复回调不重复加。
- 同一 provider order id 不同 payload 返回冲突并进入人工处理。
- 余额不足时 game_orders 失败wallet 不写成功交易。
- 后台可以按平台维护游戏、上下架、排序和查看回调日志。
- 后台补单只能通过 game-service 和 wallet-service 幂等命令完成,不能直接改余额。
- cron-service 只触发补单批处理,不直接拥有游戏或钱包状态。

View File

@ -40,7 +40,10 @@
| Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- |
| `/api/v1` envelope | `DONE` | gateway 业务响应使用 `{code,message,request_id,data}` | healthcheck 不套 envelope保持现状 |
| 房间命令 HTTP 路由 | `DONE` | create/join/leave/mic/mute/kick/sendGift 路由存在 | OpenAPI 需持续同步最新字段 |
| 房间命令 HTTP 路由 | `DONE` | create/join/leave/close-as-leave/mic/mute/kick/sendGift 路由存在 | OpenAPI 需持续同步最新字段 |
| 房间详情页聚合接口 | `DONE` | `/rooms/{room_id}/detail` 返回 room/viewer/seats/rank_top/online_count/permissions/im/rtc | 头像框、座驾、VIP、等级等展示 read model 仍需接入 |
| 房间在线用户分页 | `DONE` | `/rooms/{room_id}/online-users` 读取 `room_user_presence` 分页投影 | 大页码仍是 offset 分页,超大房间可升级 cursor |
| 房间礼物面板 | `DONE` | `/rooms/{room_id}/gift-panel` 聚合 COIN 余额、收礼人、礼物 tabs、礼物配置和数量预设 | all_mic/all_room/couple 账务拆单策略未开放 |
| 房间命令 `command_id` | `DONE` | 创建房间由 gateway 生成内部 `command_id`;其他写命令支持 body `command_id`,未传时 gateway 生成一次性值 | 客户端只在非创建类重试动作复用 command_id |
| `request_id` 追踪 | `DONE` | request_id 透传 RequestMeta不作为幂等键 | 日志和链路追踪系统可继续完善 |
| IM UserSig 签发 | `DONE` | `/api/v1/im/usersig` 使用 access token `user_id` 签发 | 真实腾讯云应用联调和告警 |
@ -61,7 +64,7 @@
| 离房 | `DONE` | `LeaveRoom` 幂等,移除 presence释放麦位写 outbox | 客户端收到离房/被踢后退出 IM/RTC 需联调 |
| stale presence 清理 | `DONE` | `RunPresenceStaleWorker``SweepStalePresence` 存在 | 多实例下只扫描本节点已装载 Cell需压测确认 |
| 房间恢复 | `DONE` | snapshot + command log + Redis lease 接管恢复测试存在 | 大房间 command log 回放性能和快照策略压测 |
| 房间关闭 | `TODO` | 当前无 CloseRoom 命令 | 需要状态变更、IM/RTC 退出通知、恢复语义 |
| 房间关闭 | `PARTIAL` | room-service 有 CloseRoom 命令App 设计稿右上角电源按钮当前走 `/rooms/close` 退出自己,不关闭房间 | 真正关闭房间入口只应给房主/后台,需单独确认产品入口 |
| 房间列表/发现 | `TODO` | 当前无 list/search/recommend API | 需要房间索引、热度排序、状态过滤 |
| 房间基础配置更新 | `TODO` | 当前无修改标题、公告、封面、模式等命令 | 需要定义哪些字段属于 Room Cell哪些属于房间资料表 |
@ -127,7 +130,7 @@
| --- | --- | --- | --- |
| wallet-service 账务表 | `DONE` | `wallet_accounts``wallet_transactions``wallet_entries``wallet_outbox``wallet_gift_prices` | 充值、退款、管理员调账未做 |
| 礼物扣费幂等 | `DONE` | `DebitGift` 使用 `wallet_transactions.command_id` 幂等,测试覆盖 | 冲突请求告警 |
| SendGift 房间命令 | `DONE` | room-service 先扣费,再更新 heat、gift_rank、outbox | 礼物目标、礼物表现和客户端动画联调 |
| SendGift 房间命令 | `DONE` | room-service 先扣费,再更新 heat、gift_rank、outboxHTTP 支持 `target_type + target_user_ids`,兼容旧 `target_user_id` | 当前只开放 `target_type=user`all_mic/all_room/couple 账务拆单和表现策略未做 |
| 房间热度 | `DONE` | `RoomSnapshot.heat``RoomHeatChanged` 事件 | 热度衰减/排行榜周期未做 |
| 本地礼物榜 | `DONE` | `LocalRank``gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |

View File

@ -63,6 +63,9 @@ sequenceDiagram
C->>G: GET /api/v1/rooms/snapshot
G-->>C: optional active-room resync
C->>G: GET /api/v1/rooms/{room_id}/detail
G-->>C: render-ready room detail package
loop while room page is active
C->>G: POST /api/v1/rooms/heartbeat
end
@ -80,6 +83,9 @@ sequenceDiagram
| 6 | Tencent IM SDK `joinGroup(im_group_id)` | yes | 必须在 JoinRoom 成功后执行IM 回调守卫会校验 room-service presence |
| 7 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 使用 JoinRoom 响应里的 `rtc.token` 进入真实音频频道旁听 |
| 8 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence避免 stale timeout |
| 9 | `GET /api/v1/rooms/{room_id}/detail` | optional after join/restore | 详情页聚合包;用于 JoinRoom 后补偿刷新、回前台、设计稿首屏重绘 |
| 10 | `GET /api/v1/rooms/{room_id}/gift-panel` | lazy on gift button | 打开礼物面板时再拉,不放进房间首屏 |
| 11 | `GET /api/v1/rooms/{room_id}/online-users` | lazy on online count | 点击右上角在线人数时分页拉取 |
如果 JoinRoom 响应里的 `rtc.available=false`,客户端可以先展示房间页并重试 `POST /api/v1/rtc/token`RTC 失败不能回滚已经成功的业务 presence。
@ -207,6 +213,127 @@ Rules:
- JoinRoom 成功后才能调用腾讯 IM SDK `joinGroup`;列表里的 `im_group_id` 只用于准备,不代表入群授权。
- JoinRoom 会尝试内嵌签发 RTC token签发失败时返回 `rtc.available=false`,客户端可以继续展示房间并单独重试 `/api/v1/rtc/token`
### Room Detail
```http
GET /api/v1/rooms/{room_id}/detail
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"room": {},
"viewer": {},
"seats": [],
"rank_top": [],
"online_count": 30,
"permissions": {},
"profiles": [],
"im": {},
"rtc": {}
}
```
Rules:
- `detail` 只读 Room Cell/snapshot不刷新 heartbeat不隐式进房。
- viewer 必须仍在 room-service presence 内;未进房、被踢或被 ban 返回 `PERMISSION_DENIED`
- `permissions.can_close_room` 表示设计稿右上角电源按钮可用,但当前语义是“退出房间”,不是关闭房间生命周期。
- `profiles` 只返回首屏需要的 owner、host、viewer、麦位用户和榜单 top 用户。完整在线用户资料走在线用户分页或 `users/room-display-profiles:batch`
### Online Users
```http
GET /api/v1/rooms/{room_id}/online-users?page=1&page_size=50
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"items": [
{
"user_id": "10002",
"role": "audience",
"last_seen_at_ms": 1778000000000,
"profile": {}
}
],
"total": 30,
"page": 1,
"page_size": 50,
"server_time_ms": 1778000000000
}
```
Rules:
- 该接口读 `room_user_presence` 分页投影,不拉完整 `RoomSnapshot` 做列表分页。
- `page_size` 默认 50服务端最大 100。
- 用户展示资料失败不影响在线列表主数据,客户端可用 `GET /api/v1/users/room-display-profiles:batch` 补偿。
### Gift Panel
```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"coin_balance": 1123123,
"recipients": [],
"tabs": [],
"gifts": [],
"quantity_presets": [1, 9, 99, 999]
}
```
Rules:
- 打开礼物面板时再请求,不放进 `rooms/detail`,避免房间首屏每次都查钱包和礼物大列表。
- `gifts` 来自 wallet-service 礼物配置,按房间 `visible_region_id` 过滤。
- `recipients` 首版包含麦上用户;超过一个麦上用户时附带 `target_type=all_mic` 占位。
- `coin_balance` 只查 `COIN` 余额;余额不足时送礼接口仍以 wallet-service 扣费结果为准。
### Room Display Profiles
```http
GET /api/v1/users/room-display-profiles:batch?user_ids=10001,10002
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"profiles": [
{
"user_id": "10002",
"username": "Jack",
"avatar": "https://cdn.example/avatar.png",
"display_user_id": "123456",
"vip": {},
"level": {},
"badges": [],
"avatar_frame": {},
"vehicle": {},
"charm": 10
}
]
}
```
Rules:
- 该接口专门服务房间麦位、公屏和礼物动效展示;不要复用“我的页”或用户主页重接口。
- 当前已接入用户基础资料,`vip/level/badges/avatar_frame/vehicle/charm` 保持稳定字段,后续接入对应 read model 后填充。
### IM UserSig
```http
@ -509,12 +636,14 @@ Rules:
| Interface | Body | Result |
| --- | --- | --- |
| `POST /api/v1/rooms/mic/lock` | `room_id, command_id, seat_no, locked` | 锁/解锁麦位 |
| `POST /api/v1/rooms/mic/mute` | `room_id, command_id, target_user_id, muted` | 修改麦克风静音状态;该状态会同步给房间其他用户 |
| `POST /api/v1/rooms/chat/enabled` | `room_id, command_id, enabled` | 开/关公屏,影响发言守卫 |
| `POST /api/v1/rooms/admin/set` | `room_id, command_id, target_user_id, is_admin` | 设置或取消房管 |
| `POST /api/v1/rooms/host/transfer` | `room_id, command_id, target_user_id` | 转移主持人 |
| `POST /api/v1/rooms/user/mute` | `room_id, command_id, target_user_id, muted, reason` | 禁言或解禁 |
| `POST /api/v1/rooms/user/kick` | `room_id, command_id, target_user_id, reason` | 踢人并 ban释放 presence 和麦位 |
| `POST /api/v1/rooms/user/unban` | `room_id, command_id, target_user_id, reason` | 解除房间 ban |
| `POST /api/v1/rooms/close` | `room_id, command_id` | 设计稿电源按钮:当前用户退出房间,不关闭房间 |
Management event handling:
@ -536,7 +665,8 @@ Request:
{
"room_id": "lalu_xxx",
"command_id": "cmd_send_gift_<room_id>_<user_id>_<nonce>",
"target_user_id": 10002,
"target_type": "user",
"target_user_ids": [10002],
"gift_id": "rose",
"gift_count": 1
}
@ -545,6 +675,8 @@ Request:
Rules:
- `SendGift` 必须先由 wallet-service 扣费成功,再落房间表现。
- 旧客户端的 `target_user_id` 仍兼容;新客户端统一传 `target_type + target_user_ids`
- 当前已支持 `target_type=user``all_mic/all_room/couple` 先保留协议字段,等账务拆单和房间表现策略补齐后开放。
- 房间表现、热度、本地榜通过 room snapshot 和房间系统消息同步。
- 公屏文本消息不走 gateway HTTP客户端使用腾讯云 IM 群消息。
@ -625,6 +757,7 @@ Client restore rules:
当前客户端拿完整房间状态的来源:
- `JoinRoom` 响应里的 room snapshot。
- `GET /api/v1/rooms/{room_id}/detail` 的首屏聚合包。
- 房间命令响应里的 room snapshot。
- 腾讯云 IM 房间系统消息。
- `rooms/current` 的恢复摘要。

View File

@ -19,6 +19,7 @@ SQL_FILES=(
"deploy/mysql/initdb/006_admin_database.sql"
"deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql"
"services/cron-service/deploy/mysql/initdb/001_cron_service.sql"
"services/game-service/deploy/mysql/initdb/001_game_service.sql"
"deploy/mysql/initdb/999_local_grants.sql"
)

View File

@ -17,6 +17,7 @@ import (
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
jobrunner "hyapp-admin-server/internal/job"
@ -30,6 +31,7 @@ import (
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
healthmodule "hyapp-admin-server/internal/modules/health"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
jobmodule "hyapp-admin-server/internal/modules/job"
@ -167,6 +169,12 @@ func main() {
}
defer activityConn.Close()
gameConn, err := grpc.Dial(cfg.GameService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fatalRuntime("connect_game_service_failed", err)
}
defer gameConn.Close()
auth := service.NewAuthService(cfg.JWTSecret, cfg.AccessTokenTTL)
var runner *jobrunner.Runner
var jobStatus healthmodule.JobStatusProvider
@ -202,6 +210,7 @@ func main() {
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),

View File

@ -43,6 +43,9 @@ wallet_service:
activity_service:
addr: "127.0.0.1:13006"
request_timeout: "3s"
game_service:
addr: "127.0.0.1:13008"
request_timeout: "3s"
tencent-cos:
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"

View File

@ -37,6 +37,7 @@ type Config struct {
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
}
type MigrationConfig struct {
@ -60,6 +61,11 @@ type ActivityServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type GameServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -141,6 +147,10 @@ func Default() Config {
Addr: "127.0.0.1:13006",
RequestTimeout: 3 * time.Second,
},
GameService: GameServiceConfig{
Addr: "127.0.0.1:13008",
RequestTimeout: 3 * time.Second,
},
TencentCOS: TencentCOSConfig{
Enabled: false,
ObjectPrefix: "admin",
@ -223,6 +233,25 @@ func (cfg *Config) Normalize() {
if cfg.Jobs.ArtifactDir == "" {
cfg.Jobs.ArtifactDir = "storage/exports"
}
cfg.UserService.Addr = strings.TrimSpace(cfg.UserService.Addr)
if cfg.UserService.RequestTimeout <= 0 {
cfg.UserService.RequestTimeout = 3 * time.Second
}
cfg.WalletService.Addr = strings.TrimSpace(cfg.WalletService.Addr)
if cfg.WalletService.RequestTimeout <= 0 {
cfg.WalletService.RequestTimeout = 3 * time.Second
}
cfg.ActivityService.Addr = strings.TrimSpace(cfg.ActivityService.Addr)
if cfg.ActivityService.RequestTimeout <= 0 {
cfg.ActivityService.RequestTimeout = 3 * time.Second
}
cfg.GameService.Addr = strings.TrimSpace(cfg.GameService.Addr)
if cfg.GameService.Addr == "" {
cfg.GameService.Addr = "127.0.0.1:13008"
}
if cfg.GameService.RequestTimeout <= 0 {
cfg.GameService.RequestTimeout = 3 * time.Second
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -309,6 +338,12 @@ func (cfg Config) Validate() error {
if cfg.ActivityService.RequestTimeout <= 0 {
return errors.New("activity_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.GameService.Addr) == "" {
return errors.New("game_service.addr is required")
}
if cfg.GameService.RequestTimeout <= 0 {
return errors.New("game_service.request_timeout must be greater than 0")
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")

View File

@ -0,0 +1,46 @@
package gameclient
import (
"context"
gamev1 "hyapp.local/api/proto/game/v1"
"google.golang.org/grpc"
)
// Client 是 admin-server 管理游戏平台和目录配置的最小 gRPC 依赖。
type Client interface {
ListPlatforms(ctx context.Context, req *gamev1.ListPlatformsRequest) (*gamev1.ListPlatformsResponse, error)
UpsertPlatform(ctx context.Context, req *gamev1.UpsertPlatformRequest) (*gamev1.PlatformResponse, error)
ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error)
UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error)
SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error)
}
type GRPCClient struct {
client gamev1.GameAdminServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: gamev1.NewGameAdminServiceClient(conn)}
}
func (c *GRPCClient) ListPlatforms(ctx context.Context, req *gamev1.ListPlatformsRequest) (*gamev1.ListPlatformsResponse, error) {
return c.client.ListPlatforms(ctx, req)
}
func (c *GRPCClient) UpsertPlatform(ctx context.Context, req *gamev1.UpsertPlatformRequest) (*gamev1.PlatformResponse, error) {
return c.client.UpsertPlatform(ctx, req)
}
func (c *GRPCClient) ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error) {
return c.client.ListCatalog(ctx, req)
}
func (c *GRPCClient) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error) {
return c.client.UpsertCatalog(ctx, req)
}
func (c *GRPCClient) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
return c.client.SetGameStatus(ctx, req)
}

View File

@ -0,0 +1,73 @@
package gamemanagement
import gamev1 "hyapp.local/api/proto/game/v1"
type platformDTO struct {
AppCode string `json:"appCode"`
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
SortOrder int32 `json:"sortOrder"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type catalogDTO struct {
AppCode string `json:"appCode"`
GameID string `json:"gameId"`
PlatformCode string `json:"platformCode"`
ProviderGameID string `json:"providerGameId"`
GameName string `json:"gameName"`
Category string `json:"category"`
IconURL string `json:"iconUrl"`
CoverURL string `json:"coverUrl"`
LaunchMode string `json:"launchMode"`
Orientation string `json:"orientation"`
MinCoin int64 `json:"minCoin"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Tags []string `json:"tags"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func platformFromProto(item *gamev1.GamePlatform) platformDTO {
if item == nil {
return platformDTO{}
}
return platformDTO{
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func catalogFromProto(item *gamev1.GameCatalogItem) catalogDTO {
if item == nil {
return catalogDTO{}
}
return catalogDTO{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}

View File

@ -0,0 +1,171 @@
package gamemanagement
import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
type Handler struct {
game gameclient.Client
audit shared.OperationLogger
}
func New(game gameclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{game: game, audit: audit}
}
// ListPlatforms 返回后台游戏平台筛选项和平台配置列表。
func (h *Handler) ListPlatforms(c *gin.Context) {
resp, err := h.game.ListPlatforms(c.Request.Context(), &gamev1.ListPlatformsRequest{
Meta: requestMeta(c),
Status: strings.TrimSpace(c.Query("status")),
})
if err != nil {
response.ServerError(c, "获取游戏平台失败")
return
}
items := make([]platformDTO, 0, len(resp.GetPlatforms()))
for _, item := range resp.GetPlatforms() {
items = append(items, platformFromProto(item))
}
response.OK(c, gin.H{"items": items, "serverTimeMs": resp.GetServerTimeMs()})
}
func (h *Handler) CreatePlatform(c *gin.Context) {
h.upsertPlatform(c, "")
}
func (h *Handler) UpdatePlatform(c *gin.Context) {
h.upsertPlatform(c, strings.TrimSpace(c.Param("platform_code")))
}
// upsertPlatform 创建或更新第三方游戏平台配置;平台密钥后续走单独的密钥托管接口。
func (h *Handler) upsertPlatform(c *gin.Context, platformCode string) {
var req platformRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "游戏平台参数不正确")
return
}
resp, err := h.game.UpsertPlatform(c.Request.Context(), &gamev1.UpsertPlatformRequest{
Meta: requestMeta(c),
Platform: req.toProto(platformCode),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := platformFromProto(resp.GetPlatform())
h.auditLog(c, "upsert-game-platform", "game_platforms", item.PlatformCode, item.PlatformName)
response.OK(c, item)
}
// ListCatalog 返回游戏目录;当前 game-service 使用 cursor 扩展点,后台先按 pageSize 截断。
func (h *Handler) ListCatalog(c *gin.Context) {
pageSize := int32(parsePositiveInt(firstQuery(c, "pageSize", "page_size"), 50))
resp, err := h.game.ListCatalog(c.Request.Context(), &gamev1.ListCatalogRequest{
Meta: requestMeta(c),
PlatformCode: strings.TrimSpace(firstQuery(c, "platformCode", "platform_code")),
Status: strings.TrimSpace(c.Query("status")),
PageSize: pageSize,
Cursor: strings.TrimSpace(c.Query("cursor")),
})
if err != nil {
response.ServerError(c, "获取游戏列表失败")
return
}
items := make([]catalogDTO, 0, len(resp.GetGames()))
for _, item := range resp.GetGames() {
items = append(items, catalogFromProto(item))
}
response.OK(c, gin.H{
"items": items,
"nextCursor": resp.GetNextCursor(),
"pageSize": pageSize,
"serverTimeMs": resp.GetServerTimeMs(),
})
}
func (h *Handler) CreateCatalog(c *gin.Context) {
h.upsertCatalog(c, "")
}
func (h *Handler) UpdateCatalog(c *gin.Context) {
h.upsertCatalog(c, strings.TrimSpace(c.Param("game_id")))
}
// upsertCatalog 创建或更新内部 game_id 到 provider_game_id 的映射。
func (h *Handler) upsertCatalog(c *gin.Context, gameID string) {
var req catalogRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "游戏参数不正确")
return
}
resp, err := h.game.UpsertCatalog(c.Request.Context(), &gamev1.UpsertCatalogRequest{
Meta: requestMeta(c),
Game: req.toProto(gameID),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := catalogFromProto(resp.GetGame())
h.auditLog(c, "upsert-game-catalog", "game_catalog", item.GameID, item.GameName)
response.OK(c, item)
}
// SetGameStatus 只处理 active/maintenance/disabled 状态,不删除历史订单关联的游戏目录。
func (h *Handler) SetGameStatus(c *gin.Context) {
gameID := strings.TrimSpace(c.Param("game_id"))
if gameID == "" {
response.BadRequest(c, "游戏 ID 参数不正确")
return
}
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数不正确")
return
}
resp, err := h.game.SetGameStatus(c.Request.Context(), &gamev1.SetGameStatusRequest{
Meta: requestMeta(c),
GameId: gameID,
Status: strings.TrimSpace(req.Status),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := catalogFromProto(resp.GetGame())
h.auditLog(c, "set-game-status", "game_catalog", item.GameID, item.Status)
response.OK(c, item)
}
func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, detail string) {
shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, "success", detail)
}
func parsePositiveInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
if value > 200 {
return 200
}
return value
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,101 @@
package gamemanagement
import (
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
gamev1 "hyapp.local/api/proto/game/v1"
"github.com/gin-gonic/gin"
)
type platformRequest struct {
PlatformCode string `json:"platformCode"`
PlatformName string `json:"platformName"`
Status string `json:"status"`
APIBaseURL string `json:"apiBaseUrl"`
SortOrder int32 `json:"sortOrder"`
}
type catalogRequest struct {
GameID string `json:"gameId"`
PlatformCode string `json:"platformCode"`
ProviderGameID string `json:"providerGameId"`
GameName string `json:"gameName"`
Category string `json:"category"`
IconURL string `json:"iconUrl"`
CoverURL string `json:"coverUrl"`
LaunchMode string `json:"launchMode"`
Orientation string `json:"orientation"`
MinCoin int64 `json:"minCoin"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
Tags []string `json:"tags"`
}
type statusRequest struct {
Status string `json:"status"`
}
func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
if platformCode == "" {
platformCode = r.PlatformCode
}
return &gamev1.GamePlatform{
PlatformCode: strings.TrimSpace(platformCode),
PlatformName: strings.TrimSpace(r.PlatformName),
Status: strings.TrimSpace(r.Status),
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
SortOrder: r.SortOrder,
}
}
func (r catalogRequest) toProto(gameID string) *gamev1.GameCatalogItem {
if gameID == "" {
gameID = r.GameID
}
return &gamev1.GameCatalogItem{
GameId: strings.TrimSpace(gameID),
PlatformCode: strings.TrimSpace(r.PlatformCode),
ProviderGameId: strings.TrimSpace(r.ProviderGameID),
GameName: strings.TrimSpace(r.GameName),
Category: strings.TrimSpace(r.Category),
IconUrl: strings.TrimSpace(r.IconURL),
CoverUrl: strings.TrimSpace(r.CoverURL),
LaunchMode: strings.TrimSpace(r.LaunchMode),
Orientation: strings.TrimSpace(r.Orientation),
MinCoin: r.MinCoin,
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
Tags: compactTags(r.Tags),
}
}
func compactTags(tags []string) []string {
out := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))
for _, tag := range tags {
value := strings.TrimSpace(tag)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func requestMeta(c *gin.Context) *gamev1.RequestMeta {
return &gamev1.RequestMeta{
RequestId: middleware.CurrentRequestID(c),
Caller: "admin-server",
ActorUserId: int64(middleware.CurrentUserID(c)),
SentAtMs: time.Now().UnixMilli(),
AppCode: appctx.FromContext(c.Request.Context()),
}
}

View File

@ -0,0 +1,21 @@
package gamemanagement
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/game/platforms", middleware.RequirePermission("game:view"), h.ListPlatforms)
protected.POST("/admin/game/platforms", middleware.RequirePermission("game:update"), h.CreatePlatform)
protected.PATCH("/admin/game/platforms/:platform_code", middleware.RequirePermission("game:update"), h.UpdatePlatform)
protected.GET("/admin/game/games", middleware.RequirePermission("game:view"), h.ListCatalog)
protected.POST("/admin/game/games", middleware.RequirePermission("game:create"), h.CreateCatalog)
protected.PATCH("/admin/game/games/:game_id", middleware.RequirePermission("game:update"), h.UpdateCatalog)
protected.PATCH("/admin/game/games/:game_id/status", middleware.RequirePermission("game:status"), h.SetGameStatus)
}

View File

@ -59,6 +59,10 @@ var defaultPermissions = []model.Permission{
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "游戏查看", Code: "game:view", Kind: "menu"},
{Name: "游戏创建", Code: "game:create", Kind: "button"},
{Name: "游戏更新", Code: "game:update", Kind: "button"},
{Name: "游戏状态", Code: "game:status", Kind: "button"},
{Name: "每日任务查看", Code: "daily-task:view", Kind: "menu"},
{Name: "每日任务创建", Code: "daily-task:create", Kind: "button"},
{Name: "每日任务更新", Code: "daily-task:update", Kind: "button"},
@ -110,6 +114,7 @@ func (s *Store) seedMenus() error {
roomsID := uint(0)
paymentID := uint(0)
activityID := uint(0)
gameID := uint(0)
menus := []model.Menu{
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
@ -121,7 +126,8 @@ func (s *Store) seedMenus() error {
{Title: "资源管理", Code: "resources", Path: "", Icon: "inventory", PermissionCode: "", Sort: 67, Visible: true},
{Title: "支付管理", Code: "payment", Path: "", Icon: "wallet", PermissionCode: "", Sort: 68, Visible: true},
{Title: "活动管理", Code: "activities", Path: "", Icon: "campaign", PermissionCode: "", Sort: 69, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 70, Visible: true},
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 70, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 71, Visible: true},
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
}
@ -157,6 +163,9 @@ func (s *Store) seedMenus() error {
if syncedMenu.Code == "activities" {
activityID = syncedMenu.ID
}
if syncedMenu.Code == "games" {
gameID = syncedMenu.ID
}
}
children := []model.Menu{
@ -177,6 +186,7 @@ func (s *Store) seedMenus() error {
{ParentID: &resourceID, Title: "资源赠送", Code: "resource-grant-list", Path: "/resource-grants", Icon: "send", PermissionCode: "resource-grant:view", Sort: 70, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true},
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
{ParentID: &hostOrgID, Title: "Agency 管理", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 80, Visible: true},
@ -422,6 +432,7 @@ func defaultRolePermissionCodes(code string) []string {
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"game:view", "game:create", "game:update", "game:status",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"log:view",
"notification:view", "notification:read",
@ -429,7 +440,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "daily-task:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "payment-bill:view", "game:view", "daily-task:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -449,6 +460,7 @@ func defaultRolePermissionCodes(code string) []string {
"bd:view",
"coin-seller:view",
"payment-bill:view",
"game:view",
"daily-task:view",
"role:view",
"permission:view",
@ -478,10 +490,11 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"region:view", "region:create", "region:update", "region:status",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"game:view", "game:create", "game:update", "game:status",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
}
case "auditor", "readonly":
return []string{"room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "daily-task:view"}
return []string{"room:view", "room-config:view", "app-config:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "coin-seller:view", "payment-bill:view", "game:view", "daily-task:view"}
default:
return nil
}

View File

@ -12,6 +12,7 @@ import (
"hyapp-admin-server/internal/modules/countryregion"
"hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostorg"
"hyapp-admin-server/internal/modules/job"
@ -39,6 +40,7 @@ type Handlers struct {
CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler
Dashboard *dashboard.Handler
Game *gamemanagement.Handler
Health *health.Handler
HostOrg *hostorg.Handler
Job *job.Handler
@ -73,6 +75,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion)
dailytask.RegisterRoutes(protected, h.DailyTask)
gamemanagement.RegisterRoutes(protected, h.Game)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
dashboard.RegisterRoutes(protected, h.Dashboard)
hostorg.RegisterRoutes(protected, h.HostOrg)

View File

@ -13,17 +13,17 @@ user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004"
mysql_auto_migrate: false
tencent_im:
# Docker 本地默认关闭播报 REST避免未配置密钥时启动后访问外部腾讯云
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
# Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: "24h"
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
group_type: "ChatRoom"
request_timeout: "5s"
broadcast:
enabled: false
enabled: true
super_gift_min_value: 100000
worker_poll_interval: "1s"
worker_batch_size: 100

View File

@ -13,17 +13,17 @@ user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004"
mysql_auto_migrate: false
tencent_im:
# activity-service 只负责全局/区域播报群;本地默认关闭,配置完整后才发送腾讯云 IM REST
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
# activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: "24h"
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
group_type: "ChatRoom"
request_timeout: "5s"
broadcast:
enabled: false
enabled: true
super_gift_min_value: 100000
worker_poll_interval: "1s"
worker_batch_size: 100

View File

@ -0,0 +1,24 @@
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
ARG GOPROXY=https://goproxy.cn|https://proxy.golang.org|direct
ARG GOSUMDB=sum.golang.org
ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/game-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
FROM alpine:3.20
ENV TZ=UTC
WORKDIR /app
RUN apk add --no-cache ca-certificates && adduser -D -g '' appuser
COPY --from=builder /out/server /app/server
COPY --from=builder /out/grpc-health-probe /app/grpc-health-probe
COPY services/game-service/configs/config.docker.yaml /app/config.yaml
USER appuser
EXPOSE 13008
ENTRYPOINT ["/app/server", "-config", "/app/config.yaml"]

View File

@ -0,0 +1,54 @@
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"hyapp/pkg/logx"
"hyapp/services/game-service/internal/app"
"hyapp/services/game-service/internal/config"
)
func main() {
configPath := flag.String("config", "services/game-service/configs/config.yaml", "path to game-service config")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatal(err)
}
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
log.Fatal(err)
}
application, err := app.New(cfg)
if err != nil {
fatalRuntime("app_new_failed", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
errCh <- application.Run()
}()
select {
case <-ctx.Done():
application.Close()
case err := <-errCh:
if err != nil {
fatalRuntime("service_run_failed", err)
}
}
}
func fatalRuntime(message string, err error) {
logx.Error(context.Background(), message, err)
os.Exit(1)
}

View File

@ -0,0 +1,14 @@
service_name: game-service
node_id: game-docker
environment: local
grpc_addr: ":13008"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"
wallet_service_addr: "wallet-service:13004"
user_service_addr: "user-service:13005"
launch_session_ttl: "15m"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048

View File

@ -0,0 +1,14 @@
service_name: game-service
node_id: game-prod-1
environment: prod
grpc_addr: ":13008"
mysql_dsn: "${GAME_MYSQL_DSN}"
wallet_service_addr: "wallet-service:13004"
user_service_addr: "user-service:13005"
launch_session_ttl: "15m"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 4096

View File

@ -0,0 +1,14 @@
service_name: game-service
node_id: game-local
environment: local
grpc_addr: ":13008"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true"
wallet_service_addr: "127.0.0.1:13004"
user_service_addr: "127.0.0.1:13005"
launch_session_ttl: "15m"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048

View File

@ -0,0 +1,161 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS hyapp_game DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_game;
CREATE TABLE IF NOT EXISTS game_platforms (
app_code VARCHAR(32) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
platform_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
api_base_url VARCHAR(512) NOT NULL DEFAULT '',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL,
adapter_config JSON NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, platform_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_catalog (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
game_name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id),
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
scene VARCHAR(64) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
language VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
visible TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, rule_id),
KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_launch_sessions (
app_code VARCHAR(32) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
launch_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, session_id),
KEY idx_game_launch_user(app_code, user_id, created_at_ms),
KEY idx_game_launch_token(app_code, launch_token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_orders (
app_code VARCHAR(32) NOT NULL,
order_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
wallet_balance_after BIGINT NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
failure_code VARCHAR(64) NOT NULL DEFAULT '',
failure_message VARCHAR(256) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, order_id),
UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
request_id VARCHAR(96) NOT NULL DEFAULT '',
provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
response_code VARCHAR(64) NOT NULL DEFAULT '',
response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, callback_id),
KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS game_repair_orders (
app_code VARCHAR(32) NOT NULL,
repair_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
reason VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, repair_id),
UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO game_platforms (
app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0
) ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name);
INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name);
INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled);

View File

@ -0,0 +1,97 @@
package app
import (
"context"
"errors"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/logx"
"hyapp/services/game-service/internal/client"
"hyapp/services/game-service/internal/config"
gameservice "hyapp/services/game-service/internal/service/game"
mysqlstorage "hyapp/services/game-service/internal/storage/mysql"
grpcserver "hyapp/services/game-service/internal/transport/grpc"
)
// App 装配 game-service gRPC 入口和持久化依赖。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
repo *mysqlstorage.Repository
walletConn *grpc.ClientConn
userConn *grpc.ClientConn
closeOnce sync.Once
}
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
repo, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = repo.Close()
return nil, err
}
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = userConn.Close()
_ = walletConn.Close()
_ = repo.Close()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, client.NewWalletClient(walletConn), client.NewUserClient(userConn))
transport := grpcserver.NewServer(svc)
gamev1.RegisterGameAppServiceServer(server, transport)
gamev1.RegisterGameCallbackServiceServer(server, transport)
gamev1.RegisterGameAdminServiceServer(server, transport)
health := grpchealth.NewServingChecker("game-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
return &App{server: server, listener: listener, health: health, repo: repo, walletConn: walletConn, userConn: userConn}, nil
}
func (a *App) Run() error {
a.health.MarkServing()
err := a.server.Serve(a.listener)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.server.GracefulStop()
if a.walletConn != nil {
_ = a.walletConn.Close()
}
if a.userConn != nil {
_ = a.userConn.Close()
}
if a.repo != nil {
_ = a.repo.Close()
}
})
}

View File

@ -0,0 +1,21 @@
package client
import (
"context"
userv1 "hyapp.local/api/proto/user/v1"
"google.golang.org/grpc"
)
type UserClient struct {
client userv1.UserServiceClient
}
func NewUserClient(conn *grpc.ClientConn) *UserClient {
return &UserClient{client: userv1.NewUserServiceClient(conn)}
}
func (c *UserClient) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
return c.client.GetUser(ctx, req)
}

View File

@ -0,0 +1,24 @@
package client
import (
"context"
"google.golang.org/grpc"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
type WalletClient struct {
client walletv1.WalletServiceClient
}
func NewWalletClient(conn *grpc.ClientConn) *WalletClient {
return &WalletClient{client: walletv1.NewWalletServiceClient(conn)}
}
func (c *WalletClient) ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
return c.client.ApplyGameCoinChange(ctx, req)
}
func (c *WalletClient) GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
return c.client.GetBalances(ctx, req)
}

View File

@ -0,0 +1,92 @@
package config
import (
"fmt"
"strings"
"time"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
// Config 描述 game-service 启动所需的最小依赖。
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
GRPCAddr string `yaml:"grpc_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
WalletServiceAddr string `yaml:"wallet_service_addr"`
UserServiceAddr string `yaml:"user_service_addr"`
LaunchSessionTTL time.Duration `yaml:"launch_session_ttl"`
Log logx.Config `yaml:"log"`
}
// Default 返回本地开发默认配置。
func Default() Config {
return Config{
ServiceName: "game-service",
NodeID: "game-local",
Environment: "local",
GRPCAddr: ":13008",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true",
WalletServiceAddr: "127.0.0.1:13004",
UserServiceAddr: "127.0.0.1:13005",
LaunchSessionTTL: 15 * time.Minute,
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
}
}
// Load 从 YAML 文件读取配置,并填充默认值。
func Load(path string) (Config, error) {
cfg := Default()
if path != "" {
if err := configx.LoadYAML(path, &cfg); err != nil {
return Config{}, err
}
}
if err := cfg.Normalize(); err != nil {
return Config{}, err
}
return cfg, nil
}
// Normalize 在启动时收敛配置,避免运行期才暴露关键依赖缺失。
func (cfg *Config) Normalize() error {
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "game-service"
}
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
cfg.NodeID = cfg.ServiceName
}
cfg.Environment = strings.TrimSpace(cfg.Environment)
if cfg.Environment == "" {
cfg.Environment = "local"
}
cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr)
if cfg.GRPCAddr == "" {
cfg.GRPCAddr = ":13008"
}
cfg.MySQLDSN = strings.TrimSpace(cfg.MySQLDSN)
if cfg.MySQLDSN == "" {
return fmt.Errorf("mysql_dsn is required")
}
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
if cfg.WalletServiceAddr == "" {
return fmt.Errorf("wallet_service_addr is required")
}
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
if cfg.UserServiceAddr == "" {
return fmt.Errorf("user_service_addr is required")
}
if cfg.LaunchSessionTTL <= 0 {
cfg.LaunchSessionTTL = 15 * time.Minute
}
return nil
}

View File

@ -0,0 +1,131 @@
package game
const (
StatusActive = "active"
StatusMaintenance = "maintenance"
StatusDisabled = "disabled"
SceneVoiceRoom = "voice_room"
LaunchModeH5Popup = "h5_popup"
SessionActive = "active"
OrderStatusWalletApplying = "wallet_applying"
OrderStatusSucceeded = "succeeded"
OrderStatusFailed = "failed"
OrderStatusConflict = "conflict"
)
// Platform 是第三方游戏平台配置事实。
type Platform struct {
AppCode string
PlatformCode string
PlatformName string
Status string
APIBaseURL string
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
// CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。
type CatalogItem struct {
AppCode string
GameID string
PlatformCode string
ProviderGameID string
GameName string
Category string
IconURL string
CoverURL string
LaunchMode string
Orientation string
MinCoin int64
Status string
SortOrder int32
Tags []string
CreatedAtMS int64
UpdatedAtMS int64
}
// AppGame 是 App 语音房内看到的跨平台合并游戏卡片。
type AppGame struct {
GameID string
PlatformCode string
NameKey string
Name string
IconURL string
CoverURL string
Category string
LaunchMode string
Orientation string
MinCoin int64
Enabled bool
Maintenance bool
SortOrder int32
}
// LaunchableGame 汇总启动时需要的平台和目录配置。
type LaunchableGame struct {
CatalogItem
PlatformName string
PlatformStatus string
APIBaseURL string
}
// LaunchSession 是 H5 启动会话持久事实token 只保存 hash。
type LaunchSession struct {
AppCode string
SessionID string
UserID int64
DisplayUserID string
RoomID string
Scene string
PlatformCode string
GameID string
ProviderGameID string
LaunchTokenHash string
Status string
ExpiresAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
}
// GameOrder 是 provider_order_id 对应的钱包改账事实。
type GameOrder struct {
AppCode string
OrderID string
PlatformCode string
ProviderOrderID string
ProviderRoundID string
GameID string
ProviderGameID string
UserID int64
RoomID string
OpType string
CoinAmount int64
Status string
WalletTransactionID string
WalletBalanceAfter int64
RequestHash string
FailureCode string
FailureMessage string
CreatedAtMS int64
UpdatedAtMS int64
}
// CallbackLog 保存平台回调审计摘要,原始 body 后续可迁移到对象存储。
type CallbackLog struct {
AppCode string
CallbackID string
PlatformCode string
Operation string
RequestID string
ProviderRequestID string
SignatureValid bool
RequestHash string
ResponseCode string
ResponseBodyHash string
Status string
CreatedAtMS int64
}

View File

@ -0,0 +1,478 @@
package game
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
const defaultScene = gamedomain.SceneVoiceRoom
// Repository 隔离 game-service 持久化事实,订单和 session 不能只放内存。
type Repository interface {
Ping(ctx context.Context) error
ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error)
UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error)
ListCatalog(ctx context.Context, query ListCatalogQuery) ([]gamedomain.CatalogItem, string, error)
UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error)
SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error)
}
// WalletClient 是 game-service 调 wallet-service 的最小改账依赖。
type WalletClient interface {
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}
// UserClient 只暴露平台回调需要的用户展示资料查询能力。
type UserClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}
type Config struct {
LaunchSessionTTL time.Duration
}
type Service struct {
config Config
repository Repository
wallet WalletClient
user UserClient
now func() time.Time
}
type ListGamesQuery struct {
AppCode string
UserID int64
Scene string
RoomID string
RegionID int64
Language string
ClientPlatform string
}
type ListCatalogQuery struct {
AppCode string
PlatformCode string
Status string
PageSize int32
Cursor string
}
type LaunchCommand struct {
AppCode string
RequestID string
UserID int64
DisplayUserID string
GameID string
Scene string
RoomID string
}
type LaunchResult struct {
SessionID string
LaunchURL string
ExpiresAtMS int64
Orientation string
ServerTimeMS int64
}
// New 创建 game-service 用例层。
func New(config Config, repository Repository, wallet WalletClient, user UserClient) *Service {
if config.LaunchSessionTTL <= 0 {
config.LaunchSessionTTL = 15 * time.Minute
}
return &Service{
config: config,
repository: repository,
wallet: wallet,
user: user,
now: time.Now,
}
}
// Repository 只给 transport/admin 薄封装复用;业务主链路仍通过 Service 方法表达。
func (s *Service) Repository() Repository {
if s == nil {
return nil
}
return s.repository
}
// ListGames 返回 App 语音房里的跨平台合并游戏列表。
func (s *Service) ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
query.Scene = normalizeScene(query.Scene)
query.Language = strings.ToLower(strings.TrimSpace(query.Language))
query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
ctx = appcode.WithContext(ctx, query.AppCode)
games, err := s.repository.ListGames(ctx, query)
return games, s.now().UnixMilli(), err
}
// LaunchGame 创建短期 H5 启动会话,并把原始钱包能力限制在服务端。
func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (LaunchResult, error) {
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" {
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "launch command is incomplete")
}
if s.repository == nil {
return LaunchResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.Scene = normalizeScene(command.Scene)
command.DisplayUserID = strings.TrimSpace(command.DisplayUserID)
if command.DisplayUserID == "" {
command.DisplayUserID = strconv.FormatInt(command.UserID, 10)
}
ctx = appcode.WithContext(ctx, command.AppCode)
game, err := s.repository.GetLaunchableGame(ctx, command.AppCode, strings.TrimSpace(command.GameID))
if err != nil {
return LaunchResult{}, err
}
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
now := s.now()
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := "gt_" + randomHex(24)
expiresAt := now.Add(s.config.LaunchSessionTTL).UnixMilli()
session := gamedomain.LaunchSession{
AppCode: command.AppCode,
SessionID: sessionID,
UserID: command.UserID,
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
Scene: command.Scene,
PlatformCode: game.PlatformCode,
GameID: game.GameID,
ProviderGameID: game.ProviderGameID,
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: expiresAt,
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
}
if err := s.repository.CreateLaunchSession(ctx, session); err != nil {
return LaunchResult{}, err
}
launchURL, err := buildLaunchURL(game, session, token)
if err != nil {
return LaunchResult{}, err
}
return LaunchResult{
SessionID: sessionID,
LaunchURL: launchURL,
ExpiresAtMS: expiresAt,
Orientation: game.Orientation,
ServerTimeMS: now.UnixMilli(),
}, nil
}
// HandleCallback 保留 provider 原始请求摘要,并对 demo adapter 支持余额查询和金币改账。
func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) ([]byte, string, error) {
if req == nil || strings.TrimSpace(req.GetPlatformCode()) == "" || strings.TrimSpace(req.GetOperation()) == "" {
return nil, "", xerr.New(xerr.InvalidArgument, "callback request is incomplete")
}
if s.repository == nil {
return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
operation := strings.ToLower(strings.TrimSpace(req.GetOperation()))
requestHash := stableHashBytes(req.GetRawBody())
callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))
responseBody, contentType, statusCode, err := s.handleDemoOperation(ctx, app, req, operation, requestHash)
responseHash := stableHashBytes(responseBody)
logStatus := "succeeded"
if err != nil {
logStatus = "failed"
responseBody, contentType = jsonResponse(map[string]any{"code": string(xerr.CodeOf(err)), "message": xerr.MessageOf(err)})
statusCode = string(xerr.CodeOf(err))
responseHash = stableHashBytes(responseBody)
}
_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
AppCode: app,
CallbackID: callbackID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
Operation: operation,
RequestID: req.GetMeta().GetRequestId(),
SignatureValid: false,
RequestHash: requestHash,
ResponseCode: statusCode,
ResponseBodyHash: responseHash,
Status: logStatus,
CreatedAtMS: s.now().UnixMilli(),
})
return responseBody, contentType, err
}
func (s *Service) handleDemoOperation(ctx context.Context, app string, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, error) {
switch operation {
case "get_user_info":
result, err := s.getCallbackUserInfo(ctx, app, req)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
case "get_balance":
if s.wallet == nil {
return nil, "", "", xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, "", "", xerr.New(xerr.InvalidArgument, "invalid get_balance payload")
}
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, "", "", err
}
var balance int64
for _, item := range resp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
}
}
raw, contentType := jsonResponse(map[string]any{"code": 0, "balance": balance})
return raw, contentType, "OK", nil
case "change_coin":
result, err := s.applyCallbackCoinChange(ctx, app, req, requestHash)
if err != nil {
return nil, "", "", err
}
raw, contentType := jsonResponse(result)
return raw, contentType, "OK", nil
default:
raw, contentType := jsonResponse(map[string]any{"code": 0, "message": "ok"})
return raw, contentType, "OK", nil
}
}
func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *gamev1.CallbackRequest) (map[string]any, error) {
if s.user == nil || s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "invalid get_user_info payload")
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
RequestId: req.GetMeta().GetRequestId(),
Caller: "game-service",
SentAtMs: s.now().UnixMilli(),
AppCode: app,
},
UserId: body.UserID,
})
if err != nil {
return nil, err
}
balanceResp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
UserId: body.UserID,
AssetTypes: []string{"COIN"},
})
if err != nil {
return nil, err
}
var balance int64
for _, item := range balanceResp.GetBalances() {
if item.GetAssetType() == "COIN" {
balance = item.GetAvailableAmount()
break
}
}
user := userResp.GetUser()
return map[string]any{
"code": 0,
"user_id": body.UserID,
"display_user_id": user.GetDisplayUserId(),
"nickname": user.GetUsername(),
"avatar": user.GetAvatar(),
"country": user.GetCountry(),
"region_id": user.GetRegionId(),
"balance": balance,
}, nil
}
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
if s.wallet == nil {
return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
var body struct {
UserID int64 `json:"user_id"`
GameID string `json:"game_id"`
ProviderGameID string `json:"provider_game_id"`
ProviderOrderID string `json:"provider_order_id"`
ProviderRoundID string `json:"provider_round_id"`
OpType string `json:"op_type"`
CoinAmount int64 `json:"coin_amount"`
RoomID string `json:"room_id"`
}
if err := json.Unmarshal(req.GetRawBody(), &body); err != nil {
return nil, xerr.New(xerr.InvalidArgument, "invalid change_coin payload")
}
body.GameID = strings.TrimSpace(body.GameID)
body.ProviderOrderID = strings.TrimSpace(body.ProviderOrderID)
body.OpType = strings.TrimSpace(body.OpType)
if body.UserID <= 0 || body.GameID == "" || body.ProviderOrderID == "" || body.OpType == "" || body.CoinAmount <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "change_coin payload is incomplete")
}
if body.ProviderGameID == "" {
game, err := s.repository.GetLaunchableGame(ctx, app, body.GameID)
if err != nil {
return nil, err
}
body.ProviderGameID = game.ProviderGameID
}
order := gamedomain.GameOrder{
AppCode: app,
OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
ProviderOrderID: body.ProviderOrderID,
ProviderRoundID: body.ProviderRoundID,
GameID: body.GameID,
ProviderGameID: body.ProviderGameID,
UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID),
OpType: body.OpType,
CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying,
RequestHash: requestHash,
}
order, exists, err := s.repository.CreateGameOrder(ctx, order)
if err != nil {
return nil, err
}
if exists && order.Status == gamedomain.OrderStatusSucceeded {
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": order.WalletTransactionID, "balance_after": order.WalletBalanceAfter, "idempotent_replay": true}, nil
}
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
RequestId: req.GetMeta().GetRequestId(),
AppCode: app,
CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + body.ProviderOrderID,
UserId: body.UserID,
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
GameId: body.GameID,
ProviderOrderId: body.ProviderOrderID,
ProviderRoundId: body.ProviderRoundID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
RoomId: body.RoomID,
RequestHash: requestHash,
})
nowMs := s.now().UnixMilli()
if err != nil {
status := gamedomain.OrderStatusFailed
if xerr.IsCode(err, xerr.IdempotencyConflict) {
status = gamedomain.OrderStatusConflict
}
_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
return nil, err
}
if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
return nil, err
}
return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": walletResp.GetWalletTransactionId(), "balance_after": walletResp.GetBalanceAfter(), "idempotent_replay": walletResp.GetIdempotentReplay()}, nil
}
func normalizeScene(scene string) string {
scene = strings.ToLower(strings.TrimSpace(scene))
if scene == "" {
return defaultScene
}
return scene
}
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL)
if base == "" {
base = "https://game.example.local/h5"
}
parsed, err := url.Parse(base)
if err != nil {
return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid")
}
query := parsed.Query()
query.Set("session_id", session.SessionID)
query.Set("session_token", token)
query.Set("game_id", session.GameID)
query.Set("provider_game_id", session.ProviderGameID)
query.Set("platform_code", session.PlatformCode)
query.Set("user_id", strconv.FormatInt(session.UserID, 10))
query.Set("display_user_id", session.DisplayUserID)
query.Set("scene", session.Scene)
query.Set("room_id", session.RoomID)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func jsonResponse(value any) ([]byte, string) {
body, err := json.Marshal(value)
if err != nil {
return []byte(`{"code":"INTERNAL_ERROR","message":"internal error"}`), "application/json"
}
return body, "application/json"
}
func randomHex(size int) string {
bytes := make([]byte, size)
if _, err := rand.Read(bytes); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(bytes)
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func stableHashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}

View File

@ -0,0 +1,205 @@
package game
import (
"context"
"strings"
"testing"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
)
func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) {
repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{
CatalogItem: gamedomain.CatalogItem{
AppCode: "lalu",
GameID: "demo_rocket_001",
PlatformCode: "demo",
ProviderGameID: "rocket_001",
GameName: "Rocket",
Status: gamedomain.StatusActive,
Orientation: "portrait",
},
PlatformStatus: gamedomain.StatusActive,
APIBaseURL: "https://provider.example/h5",
},
}
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
AppCode: "lalu",
UserID: 42,
DisplayUserID: "420001",
GameID: "demo_rocket_001",
Scene: gamedomain.SceneVoiceRoom,
RoomID: "room_1",
})
if err != nil {
t.Fatalf("LaunchGame failed: %v", err)
}
if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") {
t.Fatalf("launch result mismatch: %+v", result)
}
if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.LaunchTokenHash == "" {
t.Fatalf("launch session was not persisted: %+v", repo.session)
}
repo.launchable.PlatformStatus = gamedomain.StatusMaintenance
_, err = svc.LaunchGame(context.Background(), LaunchCommand{AppCode: "lalu", UserID: 42, GameID: "demo_rocket_001"})
if !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("maintenance game should reject launch, got %v", err)
}
}
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
}
wallet := &fakeWallet{balanceAfter: 880}
svc := New(Config{}, repo, wallet, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
req := &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-callback", AppCode: "lalu"},
PlatformCode: "demo",
Operation: "change_coin",
RawBody: []byte(`{"user_id":42,"game_id":"demo_rocket_001","provider_order_id":"porder_1","op_type":"debit","coin_amount":120,"room_id":"room_1"}`),
}
raw, contentType, err := svc.HandleCallback(context.Background(), req)
if err != nil {
t.Fatalf("HandleCallback failed: %v", err)
}
if contentType != "application/json" || !strings.Contains(string(raw), `"balance_after":880`) {
t.Fatalf("callback response mismatch: contentType=%s raw=%s", contentType, raw)
}
if wallet.lastApply.GetCommandId() != "game:demo:porder_1" || wallet.lastApply.GetCoinAmount() != 120 {
t.Fatalf("wallet command mismatch: %+v", wallet.lastApply)
}
if repo.order.Status != gamedomain.OrderStatusSucceeded || repo.order.WalletTransactionID != "wtx-game-1" {
t.Fatalf("order state mismatch: %+v", repo.order)
}
raw, _, err = svc.HandleCallback(context.Background(), req)
if err != nil {
t.Fatalf("idempotent callback failed: %v", err)
}
if !strings.Contains(string(raw), `"idempotent_replay":true`) {
t.Fatalf("idempotent response mismatch: %s", raw)
}
}
func TestHandleCallbackGetUserInfoUsesUserAndWallet(t *testing.T) {
wallet := &fakeWallet{balanceAfter: 1680}
user := &fakeUser{}
svc := New(Config{}, &fakeRepository{}, wallet, user)
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-user-info", AppCode: "lalu"},
PlatformCode: "demo",
Operation: "get_user_info",
RawBody: []byte(`{"user_id":42}`),
})
if err != nil {
t.Fatalf("HandleCallback failed: %v", err)
}
if contentType != "application/json" || !strings.Contains(string(raw), `"display_user_id":"420001"`) || !strings.Contains(string(raw), `"balance":1680`) {
t.Fatalf("user info response mismatch: %s", raw)
}
if user.lastGet.GetUserId() != 42 {
t.Fatalf("user request mismatch: %+v", user.lastGet)
}
}
type fakeRepository struct {
launchable gamedomain.LaunchableGame
session gamedomain.LaunchSession
order gamedomain.GameOrder
}
func (f *fakeRepository) Ping(context.Context) error { return nil }
func (f *fakeRepository) ListGames(context.Context, ListGamesQuery) ([]gamedomain.AppGame, error) {
return []gamedomain.AppGame{{GameID: "demo_rocket_001"}}, nil
}
func (f *fakeRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
return f.launchable, nil
}
func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error {
f.session = session
return nil
}
func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
if f.order.OrderID != "" {
return f.order, true, nil
}
f.order = order
return order, false, nil
}
func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
f.order.OrderID = orderID
f.order.Status = gamedomain.OrderStatusSucceeded
f.order.WalletTransactionID = walletTransactionID
f.order.WalletBalanceAfter = balanceAfter
f.order.UpdatedAtMS = nowMs
return nil
}
func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error {
f.order.OrderID = orderID
f.order.Status = status
f.order.FailureCode = code
f.order.FailureMessage = message
f.order.UpdatedAtMS = nowMs
return nil
}
func (f *fakeRepository) ListPlatforms(context.Context, string, string) ([]gamedomain.Platform, error) {
return nil, nil
}
func (f *fakeRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) {
return gamedomain.Platform{}, nil
}
func (f *fakeRepository) ListCatalog(context.Context, ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
return nil, "", nil
}
func (f *fakeRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, nil
}
func (f *fakeRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, nil
}
type fakeWallet struct {
lastApply *walletv1.ApplyGameCoinChangeRequest
balanceAfter int64
}
func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
f.lastApply = req
return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil
}
func (f *fakeWallet) GetBalances(context.Context, *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: f.balanceAfter}}}, nil
}
type fakeUser struct {
lastGet *userv1.GetUserRequest
}
func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
f.lastGet = req
return &userv1.GetUserResponse{User: &userv1.User{
UserId: req.GetUserId(),
DisplayUserId: "420001",
Username: "tester",
Avatar: "https://cdn.example/avatar.png",
Country: "AE",
RegionId: 100,
}}, nil
}

View File

@ -0,0 +1,546 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
gameservice "hyapp/services/game-service/internal/service/game"
)
// Repository 是 game-service 的 MySQL 持久化入口。
type Repository struct {
db *sql.DB
}
// Open 创建连接池并执行幂等迁移,保证本地已有数据库也能补齐游戏表。
func Open(ctx context.Context, dsn string) (*Repository, error) {
if strings.TrimSpace(dsn) == "" {
return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
repo := &Repository{db: db}
if err := repo.Migrate(ctx); err != nil {
_ = db.Close()
return nil, err
}
return repo, nil
}
func (r *Repository) Close() error {
if r == nil || r.db == nil {
return nil
}
return r.db.Close()
}
func (r *Repository) Ping(ctx context.Context) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
return r.db.PingContext(ctx)
}
// Migrate 与 initdb 保持同构;开发阶段不保留旧 schema 兼容分支。
func (r *Repository) Migrate(ctx context.Context) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS game_platforms (
app_code VARCHAR(32) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
platform_name VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
api_base_url VARCHAR(512) NOT NULL DEFAULT '',
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
callback_ip_whitelist JSON NULL,
adapter_config JSON NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, platform_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_catalog (
app_code VARCHAR(32) NOT NULL,
game_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
game_name VARCHAR(128) NOT NULL,
category VARCHAR(64) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
launch_mode VARCHAR(32) NOT NULL DEFAULT 'h5_popup',
orientation VARCHAR(32) NOT NULL DEFAULT 'portrait',
min_coin BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
tags JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, game_id),
UNIQUE KEY uk_game_provider(app_code, platform_code, provider_game_id),
KEY idx_game_platform_status(app_code, platform_code, status, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_display_rules (
app_code VARCHAR(32) NOT NULL,
rule_id VARCHAR(96) NOT NULL,
game_id VARCHAR(96) NOT NULL,
scene VARCHAR(64) NOT NULL,
region_id BIGINT NOT NULL DEFAULT 0,
language VARCHAR(32) NOT NULL DEFAULT '',
platform VARCHAR(16) NOT NULL DEFAULT '',
visible TINYINT(1) NOT NULL DEFAULT 1,
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, rule_id),
KEY idx_game_display(app_code, scene, region_id, visible, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_launch_sessions (
app_code VARCHAR(32) NOT NULL,
session_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
launch_token_hash VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, session_id),
KEY idx_game_launch_user(app_code, user_id, created_at_ms),
KEY idx_game_launch_token(app_code, launch_token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_orders (
app_code VARCHAR(32) NOT NULL,
order_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
provider_round_id VARCHAR(160) NOT NULL DEFAULT '',
game_id VARCHAR(96) NOT NULL,
provider_game_id VARCHAR(128) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '',
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '',
wallet_balance_after BIGINT NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
raw_payload_ref VARCHAR(256) NOT NULL DEFAULT '',
failure_code VARCHAR(64) NOT NULL DEFAULT '',
failure_message VARCHAR(256) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, order_id),
UNIQUE KEY uk_game_provider_order(app_code, platform_code, provider_order_id),
KEY idx_game_order_user_time(app_code, user_id, created_at_ms),
KEY idx_game_order_round(app_code, platform_code, provider_round_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_callback_logs (
app_code VARCHAR(32) NOT NULL,
callback_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
request_id VARCHAR(96) NOT NULL DEFAULT '',
provider_request_id VARCHAR(160) NOT NULL DEFAULT '',
signature_valid TINYINT(1) NOT NULL DEFAULT 0,
request_hash VARCHAR(128) NOT NULL,
response_code VARCHAR(64) NOT NULL DEFAULT '',
response_body_hash VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, callback_id),
KEY idx_game_callback_platform_time(app_code, platform_code, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS game_repair_orders (
app_code VARCHAR(32) NOT NULL,
repair_id VARCHAR(96) NOT NULL,
platform_code VARCHAR(64) NOT NULL,
provider_order_id VARCHAR(160) NOT NULL,
reason VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
operator_admin_id BIGINT NOT NULL DEFAULT 0,
attempts INT NOT NULL DEFAULT 0,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
last_error_code VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY(app_code, repair_id),
UNIQUE KEY uk_game_repair_provider_order(app_code, platform_code, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return r.seedDefaults(ctx)
}
func (r *Repository) seedDefaults(ctx context.Context) error {
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0)
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name)`); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name)`); err != nil {
return err
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled)`)
return err
}
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status,
MIN(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
WHERE c.app_code = ?
AND c.status IN ('active', 'maintenance')
AND p.status IN ('active', 'maintenance')
AND r.scene = ?
AND r.enabled = 1
AND r.visible = 1
AND (r.region_id = 0 OR r.region_id = ?)
AND (r.language = '' OR r.language = ?)
AND (r.platform = '' OR r.platform = ?)
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gamedomain.AppGame{}
for rows.Next() {
var item gamedomain.AppGame
var gameStatus, platformStatus string
if err := rows.Scan(&item.GameID, &item.PlatformCode, &item.Name, &item.IconURL, &item.CoverURL, &item.Category, &item.LaunchMode, &item.Orientation, &item.MinCoin, &gameStatus, &platformStatus, &item.SortOrder); err != nil {
return nil, err
}
item.NameKey = "game." + item.GameID + ".name"
item.Enabled = gameStatus == gamedomain.StatusActive && platformStatus == gamedomain.StatusActive
item.Maintenance = gameStatus == gamedomain.StatusMaintenance || platformStatus == gamedomain.StatusMaintenance
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) {
row := r.db.QueryRowContext(ctx,
`SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category,
c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status,
c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms,
p.platform_name, p.status, p.api_base_url
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
WHERE c.app_code = ? AND c.game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
var item gamedomain.LaunchableGame
var tagsJSON string
if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found")
}
return gamedomain.LaunchableGame{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
return item, nil
}
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_launch_sessions (
app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.Scene,
session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
)
return err
}
func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_callback_logs (
app_code, callback_id, platform_code, operation, request_id, provider_request_id,
signature_valid, request_hash, response_code, response_body_hash, status, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(log.AppCode), log.CallbackID, log.PlatformCode, log.Operation, log.RequestID, log.ProviderRequestID,
log.SignatureValid, log.RequestHash, log.ResponseCode, log.ResponseBodyHash, log.Status, log.CreatedAtMS,
)
return err
}
func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return gamedomain.GameOrder{}, false, err
}
defer func() { _ = tx.Rollback() }()
existing, exists, err := queryOrderForUpdate(ctx, tx, order.AppCode, order.PlatformCode, order.ProviderOrderID)
if err != nil {
return gamedomain.GameOrder{}, false, err
}
if exists {
if existing.RequestHash != order.RequestHash {
_, _ = tx.ExecContext(ctx,
`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
gamedomain.OrderStatusConflict, string(xerr.IdempotencyConflict), "provider order payload conflict",
time.Now().UnixMilli(), appcode.Normalize(order.AppCode), existing.OrderID,
)
_ = tx.Commit()
return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict")
}
if err := tx.Commit(); err != nil {
return gamedomain.GameOrder{}, false, err
}
return existing, true, nil
}
nowMs := time.Now().UnixMilli()
order.AppCode = appcode.Normalize(order.AppCode)
order.CreatedAtMS = nowMs
order.UpdatedAtMS = nowMs
if _, err := tx.ExecContext(ctx,
`INSERT INTO game_orders (
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
order.UserID, order.RoomID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
); err != nil {
return gamedomain.GameOrder{}, false, err
}
if err := tx.Commit(); err != nil {
return gamedomain.GameOrder{}, false, err
}
return order, false, nil
}
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
FOR UPDATE`,
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
)
var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil
}
return gamedomain.GameOrder{}, false, err
}
return order, true, nil
}
func (r *Repository) MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE game_orders
SET status = ?, wallet_transaction_id = ?, wallet_balance_after = ?, failure_code = '', failure_message = '', updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
gamedomain.OrderStatusSucceeded, walletTransactionID, balanceAfter, nowMs, appcode.Normalize(appCode), orderID,
)
return err
}
func (r *Repository) MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error {
_, err := r.db.ExecContext(ctx,
`UPDATE game_orders SET status = ?, failure_code = ?, failure_message = ?, updated_at_ms = ?
WHERE app_code = ? AND order_id = ?`,
status, code, truncate(message, 256), nowMs, appcode.Normalize(appCode), orderID,
)
return err
}
func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
query := `SELECT app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms
FROM game_platforms WHERE app_code = ?`
args := []any{appcode.Normalize(appCode)}
if strings.TrimSpace(status) != "" {
query += " AND status = ?"
args = append(args, strings.TrimSpace(status))
}
query += " ORDER BY sort_order ASC, platform_code ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []gamedomain.Platform
for rows.Next() {
var item gamedomain.Platform
if err := rows.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error) {
nowMs := time.Now().UnixMilli()
platform.AppCode = appcode.Normalize(platform.AppCode)
if platform.CreatedAtMS == 0 {
platform.CreatedAtMS = nowMs
}
platform.UpdatedAtMS = nowMs
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name), status = VALUES(status),
api_base_url = VALUES(api_base_url), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`,
platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
)
return platform, err
}
func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
sqlText := `SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ?`
args := []any{appcode.Normalize(query.AppCode)}
if strings.TrimSpace(query.PlatformCode) != "" {
sqlText += " AND platform_code = ?"
args = append(args, strings.TrimSpace(query.PlatformCode))
}
if strings.TrimSpace(query.Status) != "" {
sqlText += " AND status = ?"
args = append(args, strings.TrimSpace(query.Status))
}
sqlText += " ORDER BY sort_order ASC, game_id ASC"
if query.PageSize > 0 {
sqlText += " LIMIT ?"
args = append(args, query.PageSize)
}
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, "", err
}
defer rows.Close()
var items []gamedomain.CatalogItem
for rows.Next() {
item, err := scanCatalog(rows)
if err != nil {
return nil, "", err
}
items = append(items, item)
}
return items, "", rows.Err()
}
func (r *Repository) UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
nowMs := time.Now().UnixMilli()
item.AppCode = appcode.Normalize(item.AppCode)
if item.CreatedAtMS == 0 {
item.CreatedAtMS = nowMs
}
item.UpdatedAtMS = nowMs
tagsJSON, _ := json.Marshal(item.Tags)
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE platform_code = VALUES(platform_code), provider_game_id = VALUES(provider_game_id),
game_name = VALUES(game_name), category = VALUES(category), icon_url = VALUES(icon_url), cover_url = VALUES(cover_url),
launch_mode = VALUES(launch_mode), orientation = VALUES(orientation), min_coin = VALUES(min_coin),
status = VALUES(status), sort_order = VALUES(sort_order), tags = VALUES(tags), updated_at_ms = VALUES(updated_at_ms)`,
item.AppCode, item.GameID, item.PlatformCode, item.ProviderGameID, item.GameName, item.Category, item.IconURL, item.CoverURL,
item.LaunchMode, item.Orientation, item.MinCoin, item.Status, item.SortOrder, string(tagsJSON), item.CreatedAtMS, item.UpdatedAtMS,
)
return item, err
}
func (r *Repository) SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error) {
if _, err := r.db.ExecContext(ctx,
`UPDATE game_catalog SET status = ?, updated_at_ms = ? WHERE app_code = ? AND game_id = ?`,
status, nowMs, appcode.Normalize(appCode), strings.TrimSpace(gameID),
); err != nil {
return gamedomain.CatalogItem{}, err
}
return r.getCatalog(ctx, appCode, gameID)
}
func (r *Repository) getCatalog(ctx context.Context, appCode string, gameID string) (gamedomain.CatalogItem, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, COALESCE(CAST(tags AS CHAR), '[]'), created_at_ms, updated_at_ms
FROM game_catalog WHERE app_code = ? AND game_id = ?`,
appcode.Normalize(appCode), strings.TrimSpace(gameID),
)
item, err := scanCatalog(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return gamedomain.CatalogItem{}, xerr.New(xerr.NotFound, "game not found")
}
return gamedomain.CatalogItem{}, err
}
return item, nil
}
type catalogScanner interface {
Scan(dest ...any) error
}
func scanCatalog(scanner catalogScanner) (gamedomain.CatalogItem, error) {
var item gamedomain.CatalogItem
var tagsJSON string
if err := scanner.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return gamedomain.CatalogItem{}, err
}
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
return item, nil
}
func truncate(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) <= max {
return value
}
return value[:max]
}

View File

@ -0,0 +1,333 @@
package grpc
import (
"context"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game"
gameservice "hyapp/services/game-service/internal/service/game"
)
// Server 把 game-service 用例层适配为 gRPC 契约。
type Server struct {
gamev1.UnimplementedGameAppServiceServer
gamev1.UnimplementedGameCallbackServiceServer
gamev1.UnimplementedGameAdminServiceServer
svc *gameservice.Service
}
func NewServer(svc *gameservice.Service) *Server {
return &Server{svc: svc}
}
func (s *Server) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, serverTimeMS, err := s.svc.ListGames(ctx, gameservice.ListGamesQuery{
AppCode: req.GetMeta().GetAppCode(),
UserID: req.GetUserId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(),
Language: req.GetLanguage(),
ClientPlatform: req.GetClientPlatform(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListGamesResponse{ServerTimeMs: serverTimeMS, Games: make([]*gamev1.AppGame, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, appGameToProto(item))
}
return resp, nil
}
func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
result, err := s.svc.LaunchGame(ctx, gameservice.LaunchCommand{
AppCode: req.GetMeta().GetAppCode(),
RequestID: req.GetMeta().GetRequestId(),
UserID: req.GetUserId(),
DisplayUserID: req.GetDisplayUserId(),
GameID: req.GetGameId(),
Scene: req.GetScene(),
RoomID: req.GetRoomId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.LaunchGameResponse{
SessionId: result.SessionID,
LaunchUrl: result.LaunchURL,
ExpiresAtMs: result.ExpiresAtMS,
Orientation: result.Orientation,
ServerTimeMs: result.ServerTimeMS,
}, nil
}
func (s *Server) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
raw, contentType, err := s.svc.HandleCallback(ctx, req)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CallbackResponse{RawBody: raw, ContentType: contentType}, nil
}
func (s *Server) ListPlatforms(ctx context.Context, req *gamev1.ListPlatformsRequest) (*gamev1.ListPlatformsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, err := s.svcRepository().ListPlatforms(ctx, req.GetMeta().GetAppCode(), req.GetStatus())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListPlatformsResponse{ServerTimeMs: time.Now().UnixMilli(), Platforms: make([]*gamev1.GamePlatform, 0, len(items))}
for _, item := range items {
resp.Platforms = append(resp.Platforms, platformToProto(item))
}
return resp, nil
}
func (s *Server) UpsertPlatform(ctx context.Context, req *gamev1.UpsertPlatformRequest) (*gamev1.PlatformResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
platform := platformFromProto(req.GetPlatform())
platform.AppCode = req.GetMeta().GetAppCode()
normalized, err := normalizePlatform(platform)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
saved, err := s.svcRepository().UpsertPlatform(ctx, normalized)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.PlatformResponse{Platform: platformToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) ListCatalog(ctx context.Context, req *gamev1.ListCatalogRequest) (*gamev1.ListCatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
items, next, err := s.svcRepository().ListCatalog(ctx, gameservice.ListCatalogQuery{
AppCode: req.GetMeta().GetAppCode(),
PlatformCode: req.GetPlatformCode(),
Status: req.GetStatus(),
PageSize: req.GetPageSize(),
Cursor: req.GetCursor(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &gamev1.ListCatalogResponse{NextCursor: next, ServerTimeMs: time.Now().UnixMilli(), Games: make([]*gamev1.GameCatalogItem, 0, len(items))}
for _, item := range items {
resp.Games = append(resp.Games, catalogToProto(item))
}
return resp, nil
}
func (s *Server) UpsertCatalog(ctx context.Context, req *gamev1.UpsertCatalogRequest) (*gamev1.CatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item := catalogFromProto(req.GetGame())
item.AppCode = req.GetMeta().GetAppCode()
normalized, err := normalizeCatalog(item)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
saved, err := s.svcRepository().UpsertCatalog(ctx, normalized)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CatalogResponse{Game: catalogToProto(saved), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) SetGameStatus(ctx context.Context, req *gamev1.SetGameStatusRequest) (*gamev1.CatalogResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
status := normalizeStatus(req.GetStatus())
if status == "" {
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "status is invalid"))
}
item, err := s.svcRepository().SetGameStatus(ctx, req.GetMeta().GetAppCode(), req.GetGameId(), status, time.Now().UnixMilli())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &gamev1.CatalogResponse{Game: catalogToProto(item), ServerTimeMs: time.Now().UnixMilli()}, nil
}
func (s *Server) svcRepository() gameservice.Repository {
if s == nil || s.svc == nil || s.svc.Repository() == nil {
return unavailableRepository{}
}
return s.svc.Repository()
}
type unavailableRepository struct{}
func (unavailableRepository) Ping(context.Context) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListGames(context.Context, gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
return gamedomain.LaunchableGame{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateLaunchSession(context.Context, gamedomain.LaunchSession) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) CreateGameOrder(context.Context, gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
return gamedomain.GameOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderSucceeded(context.Context, string, string, string, int64, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) MarkOrderFailed(context.Context, string, string, string, string, string, int64) error {
return xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListPlatforms(context.Context, string, string) ([]gamedomain.Platform, error) {
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) {
return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) ListCatalog(context.Context, gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func (unavailableRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) {
return gamedomain.CatalogItem{}, xerr.New(xerr.Unavailable, "game repository is not configured")
}
func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame {
return &gamev1.AppGame{
GameId: item.GameID,
PlatformCode: item.PlatformCode,
NameKey: item.NameKey,
Name: item.Name,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
Category: item.Category,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
MinCoin: item.MinCoin,
Enabled: item.Enabled,
Maintenance: item.Maintenance,
SortOrder: item.SortOrder,
}
}
func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
return &gamev1.GamePlatform{
AppCode: item.AppCode,
PlatformCode: item.PlatformCode,
PlatformName: item.PlatformName,
Status: item.Status,
ApiBaseUrl: item.APIBaseURL,
SortOrder: item.SortOrder,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform {
if item == nil {
return gamedomain.Platform{}
}
return gamedomain.Platform{
AppCode: item.GetAppCode(),
PlatformCode: item.GetPlatformCode(),
PlatformName: item.GetPlatformName(),
Status: item.GetStatus(),
APIBaseURL: item.GetApiBaseUrl(),
SortOrder: item.GetSortOrder(),
}
}
func catalogToProto(item gamedomain.CatalogItem) *gamev1.GameCatalogItem {
return &gamev1.GameCatalogItem{
AppCode: item.AppCode,
GameId: item.GameID,
PlatformCode: item.PlatformCode,
ProviderGameId: item.ProviderGameID,
GameName: item.GameName,
Category: item.Category,
IconUrl: item.IconURL,
CoverUrl: item.CoverURL,
LaunchMode: item.LaunchMode,
Orientation: item.Orientation,
MinCoin: item.MinCoin,
Status: item.Status,
SortOrder: item.SortOrder,
Tags: item.Tags,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func catalogFromProto(item *gamev1.GameCatalogItem) gamedomain.CatalogItem {
if item == nil {
return gamedomain.CatalogItem{}
}
return gamedomain.CatalogItem{
AppCode: item.GetAppCode(),
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
ProviderGameID: item.GetProviderGameId(),
GameName: item.GetGameName(),
Category: item.GetCategory(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
MinCoin: item.GetMinCoin(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Tags: item.GetTags(),
}
}
func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) {
item.PlatformCode = strings.TrimSpace(item.PlatformCode)
item.PlatformName = strings.TrimSpace(item.PlatformName)
item.Status = normalizeStatus(item.Status)
item.APIBaseURL = strings.TrimSpace(item.APIBaseURL)
if item.PlatformCode == "" || item.PlatformName == "" || item.Status == "" {
return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform is incomplete")
}
return item, nil
}
func normalizeCatalog(item gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
item.GameID = strings.TrimSpace(item.GameID)
item.PlatformCode = strings.TrimSpace(item.PlatformCode)
item.ProviderGameID = strings.TrimSpace(item.ProviderGameID)
item.GameName = strings.TrimSpace(item.GameName)
item.Status = normalizeStatus(item.Status)
if item.LaunchMode == "" {
item.LaunchMode = gamedomain.LaunchModeH5Popup
}
if item.Orientation == "" {
item.Orientation = "portrait"
}
if item.GameID == "" || item.PlatformCode == "" || item.ProviderGameID == "" || item.GameName == "" || item.Status == "" {
return gamedomain.CatalogItem{}, xerr.New(xerr.InvalidArgument, "game is incomplete")
}
return item, nil
}
func normalizeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case gamedomain.StatusActive:
return gamedomain.StatusActive
case gamedomain.StatusMaintenance:
return gamedomain.StatusMaintenance
case gamedomain.StatusDisabled:
return gamedomain.StatusDisabled
default:
return ""
}
}

View File

@ -13,6 +13,7 @@ room_service_addr: "room-service:13001"
user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004"
activity_service_addr: "activity-service:13006"
game_service_addr: "game-service:13008"
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"

View File

@ -13,6 +13,7 @@ room_service_addr: "127.0.0.1:13001"
user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004"
activity_service_addr: "127.0.0.1:13006"
game_service_addr: "127.0.0.1:13008"
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC"

View File

@ -30,6 +30,7 @@ type App struct {
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
activityConn *grpc.ClientConn
gameConn *grpc.ClientConn
appConfig *appconfig.MySQLReader
redisClose func() error
health *healthcheck.State
@ -66,6 +67,14 @@ func New(cfg config.Config) (*App, error) {
_ = walletConn.Close()
return nil, err
}
gameConn, err := grpc.Dial(cfg.GameServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
var roomClient client.RoomClient = client.NewGRPCRoomClient(roomConn)
var roomGuardClient client.RoomGuardClient = client.NewGRPCRoomGuardClient(roomConn)
@ -82,12 +91,14 @@ func New(cfg config.Config) (*App, error) {
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
if err != nil {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{
@ -107,6 +118,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetMessageInboxClient(messageClient)
handler.SetTaskClient(taskClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader)
}
@ -135,6 +147,7 @@ func New(cfg config.Config) (*App, error) {
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
handler.SetObjectUploader(uploader)
@ -170,6 +183,7 @@ func New(cfg config.Config) (*App, error) {
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
loginRiskRedisClose, err := configureLoginRisk(handler, cfg.LoginRisk, loginRiskConfig)
@ -184,6 +198,7 @@ func New(cfg config.Config) (*App, error) {
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
redisClose = joinClose(redisClose, loginRiskRedisClose)
@ -208,6 +223,7 @@ func New(cfg config.Config) (*App, error) {
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
_ = gameConn.Close()
return nil, err
}
@ -222,6 +238,7 @@ func New(cfg config.Config) (*App, error) {
userConn: userConn,
walletConn: walletConn,
activityConn: activityConn,
gameConn: gameConn,
appConfig: appConfigReader,
redisClose: redisClose,
health: healthState,
@ -323,6 +340,9 @@ func (a *App) Close() error {
if a.activityConn != nil {
err = errors.Join(err, a.activityConn.Close())
}
if a.gameConn != nil {
err = errors.Join(err, a.gameConn.Close())
}
if a.appConfig != nil {
err = errors.Join(err, a.appConfig.Close())
}

View File

@ -0,0 +1,39 @@
package client
import (
"context"
"google.golang.org/grpc"
gamev1 "hyapp.local/api/proto/game/v1"
)
// GameClient 抽象 gateway 对 game-service App 与回调 RPC 的依赖。
type GameClient interface {
ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error)
LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error)
HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error)
}
type grpcGameClient struct {
app gamev1.GameAppServiceClient
callback gamev1.GameCallbackServiceClient
}
func NewGRPCGameClient(conn *grpc.ClientConn) GameClient {
return &grpcGameClient{
app: gamev1.NewGameAppServiceClient(conn),
callback: gamev1.NewGameCallbackServiceClient(conn),
}
}
func (c *grpcGameClient) ListGames(ctx context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
return c.app.ListGames(ctx, req)
}
func (c *grpcGameClient) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
return c.app.LaunchGame(ctx, req)
}
func (c *grpcGameClient) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
return c.callback.HandleCallback(ctx, req)
}

View File

@ -18,6 +18,7 @@ type RoomClient interface {
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error)
SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error)
ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error)
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
@ -43,6 +44,7 @@ type RoomQueryClient interface {
GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error)
}
type grpcRoomClient struct {
@ -114,6 +116,10 @@ func (c *grpcRoomClient) ConfirmMicPublishing(ctx context.Context, req *roomv1.C
return c.client.ConfirmMicPublishing(ctx, req)
}
func (c *grpcRoomClient) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
return c.client.SetMicMute(ctx, req)
}
func (c *grpcRoomClient) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
return c.client.ApplyRTCEvent(ctx, req)
}
@ -177,3 +183,7 @@ func (c *grpcRoomQueryClient) GetCurrentRoom(ctx context.Context, req *roomv1.Ge
func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
return c.client.GetRoomSnapshot(ctx, req)
}
func (c *grpcRoomQueryClient) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
return c.client.ListRoomOnlineUsers(ctx, req)
}

View File

@ -21,6 +21,7 @@ type Config struct {
UserServiceAddr string `yaml:"user_service_addr"`
WalletServiceAddr string `yaml:"wallet_service_addr"`
ActivityServiceAddr string `yaml:"activity_service_addr"`
GameServiceAddr string `yaml:"game_service_addr"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
LoginRisk LoginRiskConfig `yaml:"login_risk"`
AppConfig AppConfigConfig `yaml:"app_config"`
@ -145,6 +146,7 @@ func Default() Config {
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006",
GameServiceAddr: "127.0.0.1:13008",
AppConfig: AppConfigConfig{
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC",
},
@ -222,12 +224,16 @@ func (cfg *Config) Normalize() error {
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr)
cfg.GameServiceAddr = strings.TrimSpace(cfg.GameServiceAddr)
if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004"
}
if cfg.ActivityServiceAddr == "" {
cfg.ActivityServiceAddr = "127.0.0.1:13006"
}
if cfg.GameServiceAddr == "" {
cfg.GameServiceAddr = "127.0.0.1:13008"
}
cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN)
cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr)
cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix)

View File

@ -0,0 +1,154 @@
package http
import (
"io"
"net/http"
"strings"
"time"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.gameClient.ListGames(request.Context(), &gamev1.ListGamesRequest{
Meta: gameMeta(request),
UserId: userID,
Scene: strings.TrimSpace(request.URL.Query().Get("scene")),
RoomId: strings.TrimSpace(request.URL.Query().Get("room_id")),
RegionId: userResp.GetUser().GetRegionId(),
Language: requestLanguage(request),
ClientPlatform: firstHeader(request, "X-App-Platform", "X-Platform"),
})
write(writer, request, gameListDataFromProto(resp), err)
}
func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
gameID := strings.TrimSpace(request.PathValue("game_id"))
if gameID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
var body struct {
Scene string `json:"scene"`
RoomID string `json:"room_id"`
}
if !decode(writer, request, &body) {
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.gameClient.LaunchGame(request.Context(), &gamev1.LaunchGameRequest{
Meta: gameMeta(request),
UserId: userID,
DisplayUserId: userResp.GetUser().GetDisplayUserId(),
GameId: gameID,
Scene: body.Scene,
RoomId: body.RoomID,
})
write(writer, request, gameLaunchDataFromProto(resp), err)
}
func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) {
if h.gameClient == nil {
http.Error(writer, "upstream service error", http.StatusBadGateway)
return
}
raw, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "invalid request body", http.StatusBadRequest)
return
}
resp, err := h.gameClient.HandleCallback(request.Context(), &gamev1.CallbackRequest{
Meta: gameMeta(request),
PlatformCode: strings.TrimSpace(request.PathValue("platform_code")),
Operation: strings.TrimSpace(request.PathValue("operation")),
RawBody: raw,
Headers: flattenedHeaders(request),
Query: flattenedQuery(request),
RemoteAddr: request.RemoteAddr,
})
if err != nil {
status := xerr.SpecOf(xerr.ReasonFromGRPC(err)).HTTPStatus
http.Error(writer, xerr.SpecOf(xerr.ReasonFromGRPC(err)).PublicMessage, status)
return
}
contentType := strings.TrimSpace(resp.GetContentType())
if contentType == "" {
contentType = "application/json"
}
writer.Header().Set("Content-Type", contentType)
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(resp.GetRawBody())
}
func gameMeta(request *http.Request) *gamev1.RequestMeta {
return &gamev1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "",
ActorUserId: auth.UserIDFromContext(request.Context()),
SentAtMs: time.Now().UnixMilli(),
AppCode: appcode.FromContext(request.Context()),
}
}
func requestLanguage(request *http.Request) string {
value := firstHeader(request, "X-App-Language", "X-Language")
if value == "" {
value = request.Header.Get("Accept-Language")
}
if index := strings.Index(value, ","); index >= 0 {
value = value[:index]
}
return strings.TrimSpace(value)
}
func flattenedHeaders(request *http.Request) map[string]string {
headers := make(map[string]string, len(request.Header))
for key, values := range request.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
return headers
}
func flattenedQuery(request *http.Request) map[string]string {
query := request.URL.Query()
result := make(map[string]string, len(query))
for key, values := range query {
if len(values) > 0 {
result[key] = values[0]
}
}
return result
}

View File

@ -0,0 +1,131 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
func TestListGamesUsesAuthenticatedUserRegionAndReturnsEnvelope(t *testing.T) {
gameClient := &fakeGatewayGameClient{listResp: &gamev1.ListGamesResponse{
Games: []*gamev1.AppGame{{
GameId: "demo_rocket_001",
PlatformCode: "demo",
NameKey: "game.demo_rocket_001.name",
Name: "Rocket",
LaunchMode: "h5_popup",
Orientation: "portrait",
Enabled: true,
}},
ServerTimeMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/games?scene=voice_room&room_id=room-1", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-App-Language", "en")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastList.GetUserId() != 42 || gameClient.lastList.GetRegionId() != 1001 || gameClient.lastList.GetScene() != "voice_room" || gameClient.lastList.GetRoomId() != "room-1" {
t.Fatalf("ListGames request mismatch: %+v", gameClient.lastList)
}
var envelope httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := envelope.Data.(map[string]any)
games := data["games"].([]any)
first := games[0].(map[string]any)
if first["platform_code"] != "demo" || first["game_id"] != "demo_rocket_001" {
t.Fatalf("game response mismatch: %+v", first)
}
}
func TestLaunchGameForwardsDisplayUserID(t *testing.T) {
gameClient := &fakeGatewayGameClient{launchResp: &gamev1.LaunchGameResponse{
SessionId: "game_sess_1",
LaunchUrl: "https://provider.example/h5?session_token=token",
ExpiresAtMs: 1700000900000,
Orientation: "portrait",
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/games/demo_rocket_001/launch", bytes.NewReader([]byte(`{"scene":"voice_room","room_id":"room-1"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastLaunch.GetGameId() != "demo_rocket_001" || gameClient.lastLaunch.GetDisplayUserId() != "100001" || gameClient.lastLaunch.GetRoomId() != "room-1" {
t.Fatalf("LaunchGame request mismatch: %+v", gameClient.lastLaunch)
}
}
func TestGameCallbackIsRawPublicResponse(t *testing.T) {
gameClient := &fakeGatewayGameClient{callbackResp: &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetGameClient(gameClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/game-callbacks/demo/change_coin?nonce=1", bytes.NewReader([]byte(`{"provider_order_id":"p1"}`)))
request.Header.Set("X-App-Code", "lalu")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"code":0}` {
t.Fatalf("callback response must not use app envelope: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if gameClient.lastCallback.GetPlatformCode() != "demo" || gameClient.lastCallback.GetOperation() != "change_coin" || string(gameClient.lastCallback.GetRawBody()) != `{"provider_order_id":"p1"}` || gameClient.lastCallback.GetQuery()["nonce"] != "1" {
t.Fatalf("callback request mismatch: %+v", gameClient.lastCallback)
}
}
type fakeGatewayGameClient struct {
listResp *gamev1.ListGamesResponse
launchResp *gamev1.LaunchGameResponse
callbackResp *gamev1.CallbackResponse
lastList *gamev1.ListGamesRequest
lastLaunch *gamev1.LaunchGameRequest
lastCallback *gamev1.CallbackRequest
}
func (f *fakeGatewayGameClient) ListGames(_ context.Context, req *gamev1.ListGamesRequest) (*gamev1.ListGamesResponse, error) {
f.lastList = req
if f.listResp != nil {
return f.listResp, nil
}
return &gamev1.ListGamesResponse{}, nil
}
func (f *fakeGatewayGameClient) LaunchGame(_ context.Context, req *gamev1.LaunchGameRequest) (*gamev1.LaunchGameResponse, error) {
f.lastLaunch = req
if f.launchResp != nil {
return f.launchResp, nil
}
return &gamev1.LaunchGameResponse{}, nil
}
func (f *fakeGatewayGameClient) HandleCallback(_ context.Context, req *gamev1.CallbackRequest) (*gamev1.CallbackResponse, error) {
f.lastCallback = req
if f.callbackResp != nil {
return f.callbackResp, nil
}
return &gamev1.CallbackResponse{RawBody: []byte(`{"code":0}`), ContentType: "application/json"}, nil
}

View File

@ -0,0 +1,70 @@
package http
import gamev1 "hyapp.local/api/proto/game/v1"
type gameListData struct {
Games []gameItemData `json:"games"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type gameItemData struct {
GameID string `json:"game_id"`
PlatformCode string `json:"platform_code"`
NameKey string `json:"name_key"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
CoverURL string `json:"cover_url"`
Category string `json:"category"`
LaunchMode string `json:"launch_mode"`
Orientation string `json:"orientation"`
MinCoin int64 `json:"min_coin"`
Enabled bool `json:"enabled"`
Maintenance bool `json:"maintenance"`
SortOrder int32 `json:"sort_order"`
}
type gameLaunchData struct {
SessionID string `json:"session_id"`
LaunchURL string `json:"launch_url"`
ExpiresAtMS int64 `json:"expires_at_ms"`
Orientation string `json:"orientation"`
ServerTimeMS int64 `json:"server_time_ms"`
}
func gameListDataFromProto(resp *gamev1.ListGamesResponse) gameListData {
if resp == nil {
return gameListData{}
}
items := make([]gameItemData, 0, len(resp.GetGames()))
for _, item := range resp.GetGames() {
items = append(items, gameItemData{
GameID: item.GetGameId(),
PlatformCode: item.GetPlatformCode(),
NameKey: item.GetNameKey(),
Name: item.GetName(),
IconURL: item.GetIconUrl(),
CoverURL: item.GetCoverUrl(),
Category: item.GetCategory(),
LaunchMode: item.GetLaunchMode(),
Orientation: item.GetOrientation(),
MinCoin: item.GetMinCoin(),
Enabled: item.GetEnabled(),
Maintenance: item.GetMaintenance(),
SortOrder: item.GetSortOrder(),
})
}
return gameListData{Games: items, ServerTimeMS: resp.GetServerTimeMs()}
}
func gameLaunchDataFromProto(resp *gamev1.LaunchGameResponse) gameLaunchData {
if resp == nil {
return gameLaunchData{}
}
return gameLaunchData{
SessionID: resp.GetSessionId(),
LaunchURL: resp.GetLaunchUrl(),
ExpiresAtMS: resp.GetExpiresAtMs(),
Orientation: resp.GetOrientation(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}

View File

@ -37,6 +37,7 @@ type Handler struct {
messageClient client.MessageInboxClient
taskClient client.TaskClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
appConfigReader appConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
@ -177,6 +178,11 @@ func (h *Handler) SetBroadcastClient(broadcastClient client.BroadcastClient) {
h.broadcastClient = broadcastClient
}
// SetGameClient 注入 game-service 游戏列表、启动和回调 client。
func (h *Handler) SetGameClient(gameClient client.GameClient) {
h.gameClient = gameClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader

View File

@ -23,6 +23,7 @@ type Config struct {
Task TaskHandlers
Wallet WalletHandlers
VIP VIPHandlers
Game GameHandlers
}
type AuthHandlers struct {
@ -53,6 +54,7 @@ type AppHandlers struct {
type UserHandlers struct {
ResolveDisplayUserID http.HandlerFunc
BatchUserProfiles http.HandlerFunc
BatchRoomDisplayProfiles http.HandlerFunc
GetMyOverview http.HandlerFunc
GetMyIdentity http.HandlerFunc
GetMyHostIdentity http.HandlerFunc
@ -84,15 +86,20 @@ type RoomHandlers struct {
ListRooms http.HandlerFunc
GetCurrentRoom http.HandlerFunc
GetRoomSnapshot http.HandlerFunc
GetRoomDetail http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc
CreateRoom http.HandlerFunc
UpdateRoomProfile http.HandlerFunc
JoinRoom http.HandlerFunc
RoomHeartbeat http.HandlerFunc
LeaveRoom http.HandlerFunc
CloseRoom http.HandlerFunc
MicUp http.HandlerFunc
MicDown http.HandlerFunc
ChangeMicSeat http.HandlerFunc
ConfirmMicPublishing http.HandlerFunc
SetMicMute http.HandlerFunc
SetMicSeatLock http.HandlerFunc
SetChatEnabled http.HandlerFunc
SetRoomAdmin http.HandlerFunc
@ -132,6 +139,12 @@ type VIPHandlers struct {
PurchaseVIP http.HandlerFunc
}
type GameHandlers struct {
ListGames http.HandlerFunc
LaunchGame http.HandlerFunc
HandleCallback http.HandlerFunc
}
type routes struct {
mux *http.ServeMux
config Config
@ -152,6 +165,7 @@ func New(config Config) http.Handler {
routes.registerTaskRoutes()
routes.registerWalletRoutes()
routes.registerVIPRoutes()
routes.registerGameRoutes()
return mux
}
@ -207,6 +221,7 @@ func (r routes) registerUserRoutes() {
h := r.config.User
r.public("/users/by-display-user-id/{display_user_id}", "", h.ResolveDisplayUserID)
r.profile("/users/profiles:batch", "", h.BatchUserProfiles)
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
r.profile("/users/me/overview", "", h.GetMyOverview)
r.auth("/users/me/identity", "", h.GetMyIdentity)
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
@ -240,15 +255,20 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms", http.MethodGet, h.ListRooms)
r.profile("/rooms/current", http.MethodGet, h.GetCurrentRoom)
r.profile("/rooms/snapshot", http.MethodGet, h.GetRoomSnapshot)
r.profile("/rooms/{room_id}/detail", http.MethodGet, h.GetRoomDetail)
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)
r.profile("/rooms/join", http.MethodPost, h.JoinRoom)
r.profile("/rooms/heartbeat", http.MethodPost, h.RoomHeartbeat)
r.profile("/rooms/leave", http.MethodPost, h.LeaveRoom)
r.profile("/rooms/close", http.MethodPost, h.CloseRoom)
r.profile("/rooms/mic/up", http.MethodPost, h.MicUp)
r.profile("/rooms/mic/down", http.MethodPost, h.MicDown)
r.profile("/rooms/mic/change", http.MethodPost, h.ChangeMicSeat)
r.profile("/rooms/mic/publishing/confirm", http.MethodPost, h.ConfirmMicPublishing)
r.profile("/rooms/mic/mute", http.MethodPost, h.SetMicMute)
r.profile("/rooms/mic/lock", http.MethodPost, h.SetMicSeatLock)
r.profile("/rooms/chat/enabled", http.MethodPost, h.SetChatEnabled)
r.profile("/rooms/admin/set", http.MethodPost, h.SetRoomAdmin)
@ -291,3 +311,10 @@ func (r routes) registerVIPRoutes() {
r.profile("/vip/packages", http.MethodGet, h.ListVIPPackages)
r.profile("/vip/purchase", http.MethodPost, h.PurchaseVIP)
}
func (r routes) registerGameRoutes() {
h := r.config.Game
r.profile("/games", http.MethodGet, h.ListGames)
r.profile("/games/{game_id}/launch", http.MethodPost, h.LaunchGame)
r.public("/game-callbacks/{platform_code}/{operation}", http.MethodPost, h.HandleCallback)
}

View File

@ -41,6 +41,7 @@ type fakeRoomClient struct {
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
lastSetMicMute *roomv1.SetMicMuteRequest
lastRTCEvent *roomv1.ApplyRTCEventRequest
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
@ -157,6 +158,11 @@ func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.Con
return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
}
func (f *fakeRoomClient) SetMicMute(_ context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
f.lastSetMicMute = req
return &roomv1.SetMicMuteResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
}
func (f *fakeRoomClient) ApplyRTCEvent(_ context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
f.lastRTCEvent = req
return &roomv1.ApplyRTCEventResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
@ -249,16 +255,19 @@ type fakeRoomQueryClient struct {
lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest
resp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse
err error
feedsErr error
myRoomErr error
currentErr error
snapshotErr error
onlineErr error
}
type fakeUserDeviceClient struct {
@ -646,6 +655,17 @@ func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.Get
return &roomv1.GetRoomSnapshotResponse{}, nil
}
func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
f.lastOnline = req
if f.onlineErr != nil {
return nil, f.onlineErr
}
if f.onlineResp != nil {
return f.onlineResp, nil
}
return &roomv1.ListRoomOnlineUsersResponse{Page: req.GetPage(), PageSize: req.GetPageSize()}, nil
}
func (f *fakeUserDeviceClient) BindPushToken(_ context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
f.lastBind = req
if f.bindErr != nil {

View File

@ -10,6 +10,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
@ -369,6 +370,133 @@ func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Requ
write(writer, request, roomSnapshotDataFromProto(resp), err)
}
// getRoomDetail 返回语音房详情页首屏可直接渲染的数据。
// 它是 GetRoomSnapshot 的 App 聚合版:补 viewer 权限、IM/RTC 入房参数和首屏展示资料。
func (h *Handler) getRoomDetail(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
viewerUserID := auth.UserIDFromContext(request.Context())
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: viewerUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
profiles := h.roomDisplayProfiles(request, roomInitialProfileUserIDs(resp.GetRoom(), viewerUserID, joinRoomContributionRankLimit))
httpkit.WriteOK(writer, request, roomDetailDataFromSnapshot(resp, viewerUserID, h.joinRoomRTCData(roomID, viewerUserID), profiles))
}
// listRoomOnlineUsers 分页返回右上角在线人数弹窗需要的用户列表。
func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
page, ok := parsePositiveInt32Query(request, "page", 1)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 50)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.roomQueryClient.ListRoomOnlineUsers(request.Context(), &roomv1.ListRoomOnlineUsersRequest{
Meta: meta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
userIDs := make([]int64, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
userIDs = append(userIDs, user.GetUserId())
}
httpkit.WriteOK(writer, request, roomOnlineUserDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
}
// getRoomGiftPanel 返回房间送礼面板初始化数据。
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil || h.walletClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.PathValue("room_id"))
if !roomid.ValidStringID(roomID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
viewerUserID := auth.UserIDFromContext(request.Context())
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: viewerUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
snapshot := snapshotResp.GetRoom()
balances, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
UserId: viewerUserID,
AssetTypes: []string{"COIN"},
AppCode: appcode.FromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
Page: 1,
PageSize: 500,
ActiveOnly: true,
RegionId: snapshot.GetVisibleRegionId(),
FilterRegion: true,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
for _, gift := range giftResp.GetGifts() {
gifts = append(gifts, giftFromProto(gift))
}
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
httpkit.WriteOK(writer, request, roomGiftPanelData{
CoinBalance: coinBalanceFromProto(balances),
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
Tabs: roomGiftTabs(gifts),
Gifts: gifts,
QuantityPresets: []int32{1, 9, 99, 999},
})
}
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -590,6 +718,11 @@ func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
write(writer, request, resp, err)
}
// closeRoom 是设计稿右上角电源按钮入口:这里只退出当前用户,不关闭房间生命周期。
func (h *Handler) closeRoom(writer http.ResponseWriter, request *http.Request) {
h.leaveRoom(writer, request)
}
// micUp 把上麦 HTTP JSON 请求转换为 room-service MicUp 命令。
func (h *Handler) micUp(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -678,6 +811,27 @@ func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http
write(writer, request, resp, err)
}
// setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。
func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
Muted bool `json:"muted"`
}
if !decode(writer, request, &body) {
return
}
resp, err := h.roomClient.SetMicMute(request.Context(), &roomv1.SetMicMuteRequest{
Meta: meta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
Muted: body.Muted,
})
write(writer, request, resp, err)
}
// setMicSeatLock 把锁麦 HTTP JSON 请求转换为 room-service SetMicSeatLock 命令。
func (h *Handler) setMicSeatLock(writer http.ResponseWriter, request *http.Request) {
var body struct {
@ -821,22 +975,190 @@ func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetType string `json:"target_type"`
TargetUserID int64 `json:"target_user_id"`
TargetUserIDs []int64 `json:"target_user_ids"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
}
if !decode(writer, request, &body) {
return
}
targetType := strings.TrimSpace(body.TargetType)
if targetType == "" {
targetType = "user"
}
targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs)
if targetType == "user" && len(targetUserIDs) != 1 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: meta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
GiftId: body.GiftID,
GiftCount: body.GiftCount,
Meta: meta(request, body.RoomID, body.CommandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
GiftId: body.GiftID,
GiftCount: body.GiftCount,
})
write(writer, request, resp, err)
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userIDs, ok := parseBatchUserIDs(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
httpkit.WriteOK(writer, request, map[string]any{"profiles": h.roomDisplayProfiles(request, userIDs)})
}
func (h *Handler) roomDisplayProfiles(request *http.Request, userIDs []int64) []roomDisplayProfileData {
profileMap := h.roomDisplayProfileMap(request, userIDs)
profiles := make([]roomDisplayProfileData, 0, len(userIDs))
for _, userID := range userIDs {
if profile, ok := profileMap[userID]; ok {
profiles = append(profiles, profile)
}
}
return profiles
}
func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomDisplayProfileData {
if h.userProfileClient == nil {
return nil
}
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
return nil
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: authRequestMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
// 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。
return nil
}
profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers()))
for _, userID := range userIDs {
user := resp.GetUsers()[userID]
if user == nil {
continue
}
profiles[userID] = roomDisplayProfileData{
UserID: formatOptionalUserID(user.GetUserId()),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
DisplayUserID: user.GetDisplayUserId(),
VIP: map[string]any{},
Level: map[string]any{},
Badges: []map[string]any{},
AvatarFrame: map[string]any{},
Vehicle: map[string]any{},
Charm: 0,
}
}
return profiles
}
func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1)
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
userIDs = append(userIDs, seat.GetUserId())
}
}
if len(userIDs) == 0 {
userIDs = append(userIDs, snapshot.GetHostUserId())
}
return uniquePositiveUserIDs(userIDs)
}
func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDisplayProfileData) []roomGiftRecipientData {
userIDs := roomGiftRecipientUserIDs(snapshot)
recipients := make([]roomGiftRecipientData, 0, len(userIDs)+1)
if len(userIDs) > 1 {
recipients = append(recipients, roomGiftRecipientData{TargetType: "all_mic", Label: "all_mic"})
}
seatByUser := make(map[int64]int32, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
seatByUser[seat.GetUserId()] = seat.GetSeatNo()
}
}
for _, userID := range userIDs {
recipient := roomGiftRecipientData{
TargetType: "user",
UserID: formatOptionalUserID(userID),
SeatNo: seatByUser[userID],
}
if profile, ok := profiles[userID]; ok {
copied := profile
recipient.Profile = &copied
recipient.Label = copied.Username
}
recipients = append(recipients, recipient)
}
return recipients
}
func roomGiftTabs(gifts []giftConfigData) []roomGiftTabData {
tabs := []roomGiftTabData{{Key: "all", Order: 0}}
seen := map[string]bool{"all": true}
for _, gift := range gifts {
key := strings.TrimSpace(gift.GiftTypeCode)
if key == "" || seen[key] {
continue
}
seen[key] = true
tabs = append(tabs, roomGiftTabData{Key: key, Order: int32(len(tabs) * 10)})
}
return tabs
}
func coinBalanceFromProto(resp *walletv1.GetBalancesResponse) int64 {
for _, balance := range resp.GetBalances() {
if balance.GetAssetType() == "COIN" {
return balance.GetAvailableAmount()
}
}
return 0
}
func uniquePositiveUserIDs(userIDs []int64) []int64 {
seen := make(map[int64]bool, len(userIDs))
result := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 || seen[userID] {
continue
}
seen[userID] = true
result = append(result, userID)
}
return result
}
func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 {
result := uniquePositiveUserIDs(targetUserIDs)
if len(result) == 0 && targetUserID > 0 {
result = append(result, targetUserID)
}
return result
}
func firstUserID(userIDs []int64) int64 {
if len(userIDs) == 0 {
return 0
}
return userIDs[0]
}

View File

@ -51,6 +51,83 @@ type joinRoomData struct {
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomDetailData struct {
Room roomInitialData `json:"room"`
Viewer roomViewerData `json:"viewer"`
Seats []roomSeatData `json:"seats"`
RankTop []roomRankItemData `json:"rank_top"`
OnlineCount int32 `json:"online_count"`
Permissions roomPermissionsData `json:"permissions"`
Profiles []roomDisplayProfileData `json:"profiles,omitempty"`
IM roomIMData `json:"im"`
RTC roomRTCData `json:"rtc"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomOnlineUsersData struct {
Items []roomOnlineUserData `json:"items"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomOnlineUserData struct {
UserID string `json:"user_id"`
Role string `json:"role,omitempty"`
LastSeenAtMS int64 `json:"last_seen_at_ms,omitempty"`
Profile *roomDisplayProfileData `json:"profile,omitempty"`
}
type roomPermissionsData struct {
IsOwner bool `json:"is_owner"`
IsHost bool `json:"is_host"`
IsAdmin bool `json:"is_admin"`
IsMuted bool `json:"is_muted"`
CanSpeak bool `json:"can_speak"`
CanSendGift bool `json:"can_send_gift"`
CanMicUp bool `json:"can_mic_up"`
CanManageRoom bool `json:"can_manage_room"`
CanLeaveRoom bool `json:"can_leave_room"`
CanCloseRoom bool `json:"can_close_room"`
CanMuteMic bool `json:"can_mute_mic"`
CanOpenGiftBox bool `json:"can_open_gift_box"`
}
type roomDisplayProfileData struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
DisplayUserID string `json:"display_user_id"`
VIP map[string]any `json:"vip"`
Level map[string]any `json:"level"`
Badges []map[string]any `json:"badges"`
AvatarFrame map[string]any `json:"avatar_frame"`
Vehicle map[string]any `json:"vehicle"`
Charm int64 `json:"charm"`
}
type roomGiftPanelData struct {
CoinBalance int64 `json:"coin_balance"`
Recipients []roomGiftRecipientData `json:"recipients"`
Tabs []roomGiftTabData `json:"tabs"`
Gifts []giftConfigData `json:"gifts"`
QuantityPresets []int32 `json:"quantity_presets"`
}
type roomGiftRecipientData struct {
TargetType string `json:"target_type"`
UserID string `json:"user_id,omitempty"`
SeatNo int32 `json:"seat_no,omitempty"`
Label string `json:"label,omitempty"`
Profile *roomDisplayProfileData `json:"profile,omitempty"`
}
type roomGiftTabData struct {
Key string `json:"key"`
Order int32 `json:"order"`
}
type updateRoomProfileData struct {
Result roomCommandResultData `json:"result"`
Room roomInitialData `json:"room"`
@ -87,6 +164,7 @@ type roomInitialData struct {
OnlineCount int32 `json:"online_count"`
SeatCount int32 `json:"seat_count"`
OccupiedSeatCount int32 `json:"occupied_seat_count"`
VisibleRegionID int64 `json:"visible_region_id,omitempty"`
Version int64 `json:"version"`
}
@ -104,6 +182,7 @@ type roomSeatData struct {
PublishState string `json:"publish_state,omitempty"`
MicSessionID string `json:"mic_session_id,omitempty"`
PublishDeadlineMS int64 `json:"publish_deadline_ms,omitempty"`
MicMuted bool `json:"mic_muted"`
}
type roomRankItemData struct {
@ -245,6 +324,7 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount,
VisibleRegionID: snapshot.GetVisibleRegionId(),
Version: snapshot.GetVersion(),
}
}
@ -273,11 +353,58 @@ func roomSeatDataFromSnapshot(snapshot *roomv1.RoomSnapshot) []roomSeatData {
PublishState: seat.GetPublishState(),
MicSessionID: seat.GetMicSessionId(),
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
MicMuted: seat.GetMicMuted(),
})
}
return items
}
func roomDetailDataFromSnapshot(resp *roomv1.GetRoomSnapshotResponse, viewerUserID int64, rtc roomRTCData, profiles []roomDisplayProfileData) roomDetailData {
if resp == nil {
return roomDetailData{}
}
snapshot := resp.GetRoom()
roomID := snapshot.GetRoomId()
viewer := protoRoomUserByID(snapshot, viewerUserID)
return roomDetailData{
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
Viewer: roomViewerDataFromProto(viewer, viewerUserID),
Seats: roomSeatDataFromSnapshot(snapshot),
RankTop: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit),
OnlineCount: int32(len(snapshot.GetOnlineUsers())),
Permissions: roomPermissionsFromSnapshot(snapshot, viewerUserID),
Profiles: profiles,
IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
RTC: rtc,
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func roomPermissionsFromSnapshot(snapshot *roomv1.RoomSnapshot, viewerUserID int64) roomPermissionsData {
if snapshot == nil || viewerUserID <= 0 {
return roomPermissionsData{}
}
isOwner := snapshot.GetOwnerUserId() == viewerUserID
isHost := snapshot.GetHostUserId() == viewerUserID
isAdmin := containsInt64(snapshot.GetAdminUserIds(), viewerUserID)
isMuted := containsInt64(snapshot.GetMuteUserIds(), viewerUserID)
inRoom := protoRoomUserByID(snapshot, viewerUserID) != nil
return roomPermissionsData{
IsOwner: isOwner,
IsHost: isHost,
IsAdmin: isAdmin,
IsMuted: isMuted,
CanSpeak: inRoom && snapshot.GetChatEnabled() && !isMuted,
CanSendGift: inRoom && snapshot.GetStatus() == "active",
CanMicUp: inRoom && snapshot.GetStatus() == "active" && protoSeatByUserID(snapshot, viewerUserID) == nil,
CanManageRoom: inRoom && (isOwner || isHost || isAdmin),
CanLeaveRoom: inRoom,
CanCloseRoom: inRoom,
CanMuteMic: inRoom && protoSeatByUserID(snapshot, viewerUserID) != nil,
CanOpenGiftBox: inRoom && snapshot.GetStatus() == "active",
}
}
func roomRankDataFromSnapshot(snapshot *roomv1.RoomSnapshot, limit int) []roomRankItemData {
rank := snapshot.GetGiftRank()
if limit > 0 && len(rank) > limit {
@ -322,6 +449,65 @@ func roomInitialProfileUserIDs(snapshot *roomv1.RoomSnapshot, viewerUserID int64
return userIDs
}
func roomOnlineUserDataFromProto(resp *roomv1.ListRoomOnlineUsersResponse, profiles map[int64]roomDisplayProfileData) roomOnlineUsersData {
if resp == nil {
return roomOnlineUsersData{}
}
items := make([]roomOnlineUserData, 0, len(resp.GetUsers()))
for _, user := range resp.GetUsers() {
item := roomOnlineUserData{
UserID: formatOptionalUserID(user.GetUserId()),
Role: user.GetRole(),
LastSeenAtMS: user.GetLastSeenAtMs(),
}
if profile, ok := profiles[user.GetUserId()]; ok {
copied := profile
item.Profile = &copied
}
items = append(items, item)
}
return roomOnlineUsersData{
Items: items,
Total: resp.GetTotal(),
Page: resp.GetPage(),
PageSize: resp.GetPageSize(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
func protoRoomUserByID(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
if snapshot == nil || userID <= 0 {
return nil
}
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}
func protoSeatByUserID(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatState {
if snapshot == nil || userID <= 0 {
return nil
}
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() == userID {
return seat
}
}
return nil
}
func containsInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func roomSeatCounts(snapshot *roomv1.RoomSnapshot) (int32, int32) {
seatCount := int32(len(snapshot.GetMicSeats()))
occupiedSeatCount := int32(0)

View File

@ -44,6 +44,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
User: httproutes.UserHandlers{
ResolveDisplayUserID: h.resolveDisplayUserID,
BatchUserProfiles: h.batchUserProfiles,
BatchRoomDisplayProfiles: h.batchRoomDisplayProfiles,
GetMyOverview: h.getMyOverview,
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
@ -73,15 +74,20 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListRooms: h.listRooms,
GetCurrentRoom: h.getCurrentRoom,
GetRoomSnapshot: h.getRoomSnapshot,
GetRoomDetail: h.getRoomDetail,
ListRoomOnlineUsers: h.listRoomOnlineUsers,
GetRoomGiftPanel: h.getRoomGiftPanel,
CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile,
JoinRoom: h.joinRoom,
RoomHeartbeat: h.roomHeartbeat,
LeaveRoom: h.leaveRoom,
CloseRoom: h.closeRoom,
MicUp: h.micUp,
MicDown: h.micDown,
ChangeMicSeat: h.changeMicSeat,
ConfirmMicPublishing: h.confirmMicPublishing,
SetMicMute: h.setMicMute,
SetMicSeatLock: h.setMicSeatLock,
SetChatEnabled: h.setChatEnabled,
SetRoomAdmin: h.setRoomAdmin,
@ -116,5 +122,10 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ListVIPPackages: h.listVIPPackages,
PurchaseVIP: h.purchaseVIP,
},
Game: httproutes.GameHandlers{
ListGames: h.listGames,
LaunchGame: h.launchGame,
HandleCallback: h.handleGameCallback,
},
})
}

View File

@ -10,7 +10,7 @@ log:
grpc_addr: ":13001"
lease_ttl: "10s"
rank_limit: 20
snapshot_every_n: 1
snapshot_every_n: 10
wallet_service_addr: "wallet-service:13004"
activity_service_addr: "activity-service:13006"
tencent_im:
@ -35,9 +35,12 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s"
outbox_worker:
# Docker 本地默认启动补偿 worker验证同步 IM 失败后的 room_outbox 补投闭环。
# Docker 本地默认启动补偿 worker验证 IM/activity 异步投递、退避和死信闭环。
enabled: true
poll_interval: "1s"
batch_size: 100
publish_timeout: "3s"
retry_strategy: "fixed_interval"
retry_strategy: "exponential_backoff"
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"

View File

@ -10,11 +10,11 @@ log:
grpc_addr: ":13001"
lease_ttl: "10s"
rank_limit: 20
snapshot_every_n: 1
snapshot_every_n: 10
wallet_service_addr: "wallet-service.internal:13004"
activity_service_addr: "activity-service.internal:13006"
tencent_im:
# 线上开启后,CreateRoom 会先确保腾讯云 IM 群组存在
# 线上开启后,腾讯云 IM 建群和房间系统消息都由 room_outbox worker 异步投递
enabled: true
# 腾讯云 IM 应用 ID必须和 gateway-service 使用同一个 SDKAppID。
sdk_app_id: 1400000000
@ -28,7 +28,7 @@ tencent_im:
endpoint: "adminapisgp.im.qcloud.com"
# 语音房默认用 ChatRoom 保留基础群历史;改 AVChatRoom 前要确认历史和人数语义。
group_type: "ChatRoom"
# 同步建群/发房间系统消息的单次公网调用超时。
# outbox worker 单次公网调用超时。
request_timeout: "5s"
mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
mysql_max_open_conns: 80
@ -42,9 +42,12 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s"
outbox_worker:
# 线上默认启动补偿 worker腾讯云 IM 抖动时不回滚 Room Cell 状态。
# 线上默认启动补偿 worker腾讯云 IM/activity-service 抖动时不回滚 Room Cell 状态。
enabled: true
poll_interval: "1s"
batch_size: 100
publish_timeout: "3s"
retry_strategy: "fixed_interval"
retry_strategy: "exponential_backoff"
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"

View File

@ -9,7 +9,7 @@ log:
max_payload_bytes: 2048
lease_ttl: "10s"
rank_limit: 20
snapshot_every_n: 1
snapshot_every_n: 10
grpc_addr: ":13001"
wallet_service_addr: "127.0.0.1:13004"
activity_service_addr: "127.0.0.1:13006"
@ -24,7 +24,7 @@ tencent_im:
endpoint: "adminapisgp.im.qcloud.com"
# 语音房群类型。ChatRoom 保留基础群历史;如改 AVChatRoom要重新评估历史能力。
group_type: "ChatRoom"
# room-service 同步建群和发送房间系统消息的单次 REST 超时。
# outbox worker 建群和发送房间系统消息的单次 REST 超时。
request_timeout: "5s"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_max_open_conns: 20
@ -38,9 +38,12 @@ presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s"
outbox_worker:
# room_outbox 是腾讯云 IM 系统消息补偿通道;失败转为 retryable 并固定间隔重试
# room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信
enabled: true
poll_interval: "1s"
batch_size: 100
publish_timeout: "3s"
retry_strategy: "fixed_interval"
retry_strategy: "exponential_backoff"
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"

View File

@ -103,10 +103,12 @@ CREATE TABLE IF NOT EXISTS room_outbox (
envelope LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_pending (app_code, status, created_at_ms),
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -106,7 +106,8 @@ func New(cfg config.Config) (*App, error) {
directory := router.NewRedisDirectory(redisClient)
roomPublisher := integration.NewNoopRoomEventPublisher()
outboxPublisher := integration.NewNoopOutboxPublisher()
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
outboxPublishers := []integration.OutboxPublisher{activityPublisher}
if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -119,8 +120,9 @@ func New(cfg config.Config) (*App, error) {
}
tencentPublisher := integration.NewTencentIMPublisher(tencentClient)
roomPublisher = tencentPublisher
outboxPublisher = tencentPublisher
outboxPublishers = append(outboxPublishers, tencentPublisher)
}
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
// 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。
svc := roomservice.New(roomservice.Config{
@ -131,7 +133,6 @@ func New(cfg config.Config) (*App, error) {
PresenceStaleAfter: cfg.PresenceStaleAfter,
MicPublishTimeout: cfg.MicPublishTimeout,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
svc.SetEventPublisher(integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second))
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
// 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
@ -196,6 +197,9 @@ func (a *App) Run() error {
BatchSize: a.cfg.OutboxWorker.BatchSize,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
RetryStrategy: a.cfg.OutboxWorker.RetryStrategy,
MaxRetryCount: a.cfg.OutboxWorker.MaxRetryCount,
InitialBackoff: a.cfg.OutboxWorker.InitialBackoff,
MaxBackoff: a.cfg.OutboxWorker.MaxBackoff,
})
})
}

View File

@ -72,8 +72,14 @@ type OutboxWorkerConfig struct {
BatchSize int `yaml:"batch_size"`
// PublishTimeout 是单条 outbox 投递腾讯云 IM 的最大耗时。
PublishTimeout time.Duration `yaml:"publish_timeout"`
// RetryStrategy 当前只接受 fixed_interval,避免配置错误静默改变投递语义。
// RetryStrategy 当前只接受 exponential_backoff,避免配置错误静默改变投递语义。
RetryStrategy string `yaml:"retry_strategy"`
// MaxRetryCount 是转入 failed 前允许的最大失败次数。
MaxRetryCount int `yaml:"max_retry_count"`
// InitialBackoff 是首次失败后的等待时间。
InitialBackoff time.Duration `yaml:"initial_backoff"`
// MaxBackoff 是指数退避的最大等待时间。
MaxBackoff time.Duration `yaml:"max_backoff"`
}
// TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。
@ -125,7 +131,7 @@ func Default() Config {
GRPCAddr: ":13001",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
SnapshotEveryN: 10,
WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006",
TencentIM: TencentIMConfig{
@ -156,13 +162,16 @@ func Default() Config {
}
func defaultOutboxWorkerConfig() OutboxWorkerConfig {
// 默认启动补偿 worker保证同步腾讯云 IM 失败后 pending outbox 能自动重试
// 默认启动补偿 worker使用退避和死信避免外部故障刷屏
return OutboxWorkerConfig{
Enabled: true,
PollInterval: time.Second,
BatchSize: 100,
PublishTimeout: 3 * time.Second,
RetryStrategy: "fixed_interval",
RetryStrategy: "exponential_backoff",
MaxRetryCount: 10,
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
}
}
@ -210,12 +219,24 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
if cfg.PublishTimeout <= 0 {
cfg.PublishTimeout = defaults.PublishTimeout
}
if cfg.MaxRetryCount <= 0 {
cfg.MaxRetryCount = defaults.MaxRetryCount
}
if cfg.InitialBackoff <= 0 {
cfg.InitialBackoff = defaults.InitialBackoff
}
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = defaults.MaxBackoff
}
if cfg.MaxBackoff < cfg.InitialBackoff {
cfg.MaxBackoff = cfg.InitialBackoff
}
cfg.RetryStrategy = strings.TrimSpace(cfg.RetryStrategy)
if cfg.RetryStrategy == "" {
cfg.RetryStrategy = defaults.RetryStrategy
}
if cfg.RetryStrategy != defaults.RetryStrategy {
// V1 只实现 fixed_interval未知策略直接启动失败避免误以为退避或死信已生效。
// 当前只实现指数退避;未知策略直接启动失败,避免误以为退避或死信已生效。
return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy)
}

View File

@ -28,7 +28,7 @@ func TestLoad(t *testing.T) {
if cfg.WalletServiceAddr != "127.0.0.1:13004" {
t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "fixed_interval" {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
}
}
@ -55,7 +55,7 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.TencentIM.RequestTimeout != 5*time.Second {
t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout)
}
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "fixed_interval" {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
}
}
@ -75,12 +75,15 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg.OutboxWorker.BatchSize = 0
cfg.OutboxWorker.PublishTimeout = 0
cfg.OutboxWorker.RetryStrategy = ""
cfg.OutboxWorker.MaxRetryCount = 0
cfg.OutboxWorker.InitialBackoff = 0
cfg.OutboxWorker.MaxBackoff = 0
normalized, err := Normalize(cfg)
if err != nil {
t.Fatalf("Normalize failed: %v", err)
}
if normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "fixed_interval" {
if normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
}
}

View File

@ -15,11 +15,11 @@ type WalletClient interface {
DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error)
}
// RoomEventPublisher 抽象 room-service 对腾讯云 IM 的同步房间消息桥接
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它
type RoomEventPublisher interface {
// EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。
EnsureRoomGroup(ctx context.Context, roomID string, ownerUserID int64) error
// PublishRoomEvent 把房间系统事件同步推给腾讯云 IM于低时延广播
// PublishRoomEvent 把房间系统事件推给腾讯云 IM当前只供异步边界复用。
PublishRoomEvent(ctx context.Context, event tencentim.RoomEvent) error
}

View File

@ -0,0 +1,45 @@
package integration
import (
"context"
"errors"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
)
// CompositeOutboxPublisher fans one committed room outbox event to every
// asynchronous consumer. Consumers must be idempotent by event_id because a
// partial failure retries the full fanout on the next worker pass.
type CompositeOutboxPublisher struct {
publishers []OutboxPublisher
}
// NewCompositeOutboxPublisher removes nil publishers and returns a no-op when
// no asynchronous consumers are configured.
func NewCompositeOutboxPublisher(publishers ...OutboxPublisher) OutboxPublisher {
filtered := make([]OutboxPublisher, 0, len(publishers))
for _, publisher := range publishers {
if publisher != nil {
filtered = append(filtered, publisher)
}
}
if len(filtered) == 0 {
return NewNoopOutboxPublisher()
}
return &CompositeOutboxPublisher{publishers: filtered}
}
// PublishOutboxEvent calls every configured consumer before returning. Returning
// a joined error keeps the event retryable until every consumer has accepted it.
func (p *CompositeOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if p == nil || len(p.publishers) == 0 {
return nil
}
var joined error
for _, publisher := range p.publishers {
if err := publisher.PublishOutboxEvent(ctx, envelope); err != nil {
joined = errors.Join(joined, err)
}
}
return joined
}

View File

@ -45,6 +45,14 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r
if p == nil || p.client == nil || envelope == nil {
return nil
}
if envelope.GetEventType() == "RoomCreated" {
var body roomeventsv1.RoomCreated
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return err
}
// 建群是腾讯 IM 的外部副作用,必须留在 outbox worker 内,不能阻塞 CreateRoom 返回。
return p.client.EnsureRoomGroup(ctx, envelope.GetRoomId(), body.GetOwnerUserId())
}
event, publish, err := roomEventFromEnvelope(envelope)
if err != nil || !publish {
@ -89,7 +97,7 @@ func NewNoopOutboxPublisher() OutboxPublisher {
// PublishOutboxEvent 直接吞掉 outbox 事件。
func (noopOutboxPublisher) PublishOutboxEvent(context.Context, *roomeventsv1.EventEnvelope) error {
// 腾讯云 IM 同步广播不依赖该 outboxactivity/audit 仍会复用同一事件源
// room_outbox worker 会把同一事件源异步投给腾讯云 IM、activity 或后续审计消费者
return nil
}
@ -141,6 +149,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"reason": body.GetReason(),
"publish_deadline_ms": fmt.Sprintf("%d", body.GetPublishDeadlineMs()),
"publish_event_time_ms": fmt.Sprintf("%d", body.GetPublishEventTimeMs()),
"mic_muted": fmt.Sprintf("%t", body.GetMicMuted()),
}
return base, true, nil
case "RoomMicSeatLocked":

View File

@ -177,6 +177,18 @@ type ConfirmMicPublishing struct {
// Type 返回命令类型。
func (ConfirmMicPublishing) Type() string { return "confirm_mic_publishing" }
// SetMicMute 定义麦克风静音状态同步请求。
type SetMicMute struct {
Base
// TargetUserID 是被修改麦克风静音态的麦上用户。
TargetUserID int64 `json:"target_user_id"`
// Muted 为 true 表示麦克风静音false 表示取消静音。
Muted bool `json:"muted"`
}
// Type 返回命令类型。
func (SetMicMute) Type() string { return "set_mic_mute" }
// ApplyRTCEvent 定义由可信腾讯 RTC 服务端回调驱动的房间状态事件。
type ApplyRTCEvent struct {
Base
@ -288,6 +300,10 @@ func (UnbanUser) Type() string { return "unban_user" }
// SendGift 定义送礼请求。
type SendGift struct {
Base
// TargetType 当前只支持 user保留字段避免后续 all_mic/all_room/couple 再改命令结构。
TargetType string `json:"target_type"`
// TargetUserIDs 是新协议目标用户集合;当前 target_type=user 时只允许一个用户。
TargetUserIDs []int64 `json:"target_user_ids,omitempty"`
// TargetUserID 是礼物接收方,当前版本要求接收方在房间内。
TargetUserID int64 `json:"target_user_id"`
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
@ -370,6 +386,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &MicDown{}
case ConfirmMicPublishing{}.Type():
cmd = &ConfirmMicPublishing{}
case SetMicMute{}.Type():
cmd = &SetMicMute{}
case ApplyRTCEvent{}.Type():
cmd = &ApplyRTCEvent{}
case ChangeMicSeat{}.Type():

View File

@ -47,6 +47,8 @@ type Record struct {
WorkerID string
// LockUntilMS 是 delivering 锁的截止时间。
LockUntilMS int64
// NextRetryAtMS 是 retryable 事件下一次允许抢占的 UTC 毫秒时间。
NextRetryAtMS int64
}
// Build 把具体事件体封装成统一 outbox 记录。

View File

@ -3,11 +3,14 @@ package service
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/logx"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/cell"
@ -43,6 +46,13 @@ type roomProfileInput struct {
// CreateRoom 创建一个新的房间执行单元,并把首个快照和事件落盘。
func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
startedAt := time.Now()
var idempotencyMS int64
var leaseMS int64
var saveMutationMS int64
var snapshotMS int64
var projectionMS int64
var saveRoomMetaMS int64
if req.GetMeta() == nil || req.GetMeta().GetRoomId() == "" {
// 创建房间必须指定 room_id后续 lease、meta、snapshot 都依赖它。
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
@ -87,23 +97,23 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
RoomShortID: normalizeRoomShortID(req.GetRoomShortId(), actorUserID),
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return nil, err
}
if seen {
// CreateRoom 幂等重试也要确保腾讯云 IM 群组存在,避免上次创建在外部桥接处失败。
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
return nil, err
}
// 重复 CreateRoom 不再写入状态,直接返回当前快照保证命令幂等。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return nil, err
}
// CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。
projectionStartedAt := time.Now()
s.projectRoomPresenceBestEffort(ctx, snapshot)
projectionMS = elapsedMS(projectionStartedAt)
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), false, elapsedMS(startedAt), idempotencyMS, 0, 0, 0, projectionMS, 0, 0, 0, false, false)
return &roomv1.CreateRoomResponse{
Result: commandResult(false, snapshot.GetVersion(), s.clock.Now()),
@ -112,7 +122,9 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}
// 创建房间前先获得 owner lease避免两个节点同时创建同一个 room_id。
leaseStartedAt := time.Now()
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, cmd.RoomID()), s.nodeID, s.clock.Now(), s.leaseTTL)
leaseMS = elapsedMS(leaseStartedAt)
if err != nil {
return nil, err
}
@ -135,10 +147,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("owner already has room: %s", existing.RoomID))
}
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
// 房间创建必须先保证外部群组可用。
return nil, err
}
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
@ -147,6 +155,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
now := s.clock.Now()
roomState := state.NewRoomState(cmd.RoomID(), cmd.OwnerUserID, cmd.HostUserID, cmd.SeatCount, cmd.Mode)
roomState.Version = 1
roomState.VisibleRegionID = cmd.VisibleRegionID
applyRoomProfileExt(roomState, roomProfileInput{
Name: cmd.RoomName,
Avatar: cmd.RoomAvatar,
@ -175,6 +184,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}
// 首次创建按 meta -> command log -> outbox -> snapshot 的顺序落盘,保证恢复有基础元数据和首版本命令。
saveMetaStartedAt := time.Now()
if err := s.repository.SaveRoomMeta(ctx, RoomMeta{
AppCode: cmd.AppCode,
RoomID: cmd.RoomID(),
@ -188,7 +198,9 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}); err != nil {
return nil, err
}
saveRoomMetaMS = elapsedMS(saveMetaStartedAt)
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: cmd.AppCode,
@ -206,17 +218,25 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}); err != nil {
return nil, err
}
saveMutationMS = elapsedMS(saveStartedAt)
snapshot := snapshotWithApp(ctx, roomState.ToProto())
snapshotStartedAt := time.Now()
if err := s.persistSnapshot(ctx, snapshot); err != nil {
return nil, err
}
snapshotMS = elapsedMS(snapshotStartedAt)
projectionStartedAt := time.Now()
s.projectRoomListBestEffort(ctx, snapshot)
// owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。
s.projectRoomPresenceBestEffort(ctx, snapshot)
projectionMS = elapsedMS(projectionStartedAt)
// 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), true, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, 0, 0, 0, true, true,
slog.Int64("save_room_meta_ms", saveRoomMetaMS),
)
return &roomv1.CreateRoomResponse{
Result: commandResult(true, snapshot.GetVersion(), now),
@ -224,6 +244,28 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
}, nil
}
func logRoomCommandTiming(ctx context.Context, roomID string, commandID string, commandType string, applied bool, totalMS int64, idempotencyMS int64, leaseMS int64, saveMutationMS int64, snapshotMS int64, projectionMS int64, walletDebitMS int64, activityMS int64, tencentIMMS int64, activityAsyncOutbox bool, tencentIMAsyncOutbox bool, extra ...slog.Attr) {
attrs := []slog.Attr{
slog.String("room_id", roomID),
slog.String("command_id", commandID),
slog.String("command_type", commandType),
slog.Bool("applied", applied),
slog.Int64("total_ms", totalMS),
slog.Int64("idempotency_ms", idempotencyMS),
slog.Int64("lease_ms", leaseMS),
slog.Int64("save_mutation_ms", saveMutationMS),
slog.Int64("snapshot_ms", snapshotMS),
slog.Int64("projection_ms", projectionMS),
slog.Int64("wallet_debit_ms", walletDebitMS),
slog.Int64("activity_ms", activityMS),
slog.Int64("tencent_im_ms", tencentIMMS),
slog.Bool("activity_async_outbox", activityAsyncOutbox),
slog.Bool("tencent_im_async_outbox", tencentIMAsyncOutbox),
}
attrs = append(attrs, extra...)
logx.Info(ctx, "room_command_timing", attrs...)
}
func normalizeRoomShortID(raw string, actorUserID int64) string {
if value := strings.TrimSpace(raw); value != "" {
return value

View File

@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"strings"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
@ -22,10 +23,17 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
ctx = contextFromMeta(ctx, req.GetMeta())
// SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。
cmd := command.SendGift{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
GiftID: req.GetGiftId(),
GiftCount: req.GetGiftCount(),
Base: baseFromMeta(req.GetMeta()),
TargetType: normalizeGiftTargetType(req.GetTargetType()),
TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
GiftID: req.GetGiftId(),
GiftCount: req.GetGiftCount(),
}
if cmd.TargetType == "" {
cmd.TargetType = "user"
}
if cmd.TargetType == "user" && len(cmd.TargetUserIDs) == 1 {
cmd.TargetUserID = cmd.TargetUserIDs[0]
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error) {
@ -34,6 +42,13 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
return mutationResult{}, nil, xerr.New(xerr.NotFound, "sender not in room")
}
if cmd.TargetType != "user" {
// v1 HTTP 契约已预留 all_mic/all_room/couple账务拆单和房间表现策略补齐前先 fail-close。
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_type is unsupported")
}
if len(cmd.TargetUserIDs) != 1 || cmd.TargetUserID <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_ids must contain one user")
}
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
// v1 要求礼物目标在房间内,避免给不存在的房间表现目标计榜。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
@ -52,6 +67,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
}
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
walletStartedAt := time.Now()
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
CommandId: cmd.ID(),
RoomId: cmd.RoomID(),
@ -62,6 +78,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
})
walletDebitMS := elapsedMS(walletStartedAt)
if err != nil {
return mutationResult{}, nil, err
}
@ -124,6 +141,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank),
commandPayload: commandPayload,
walletDebitMS: walletDebitMS,
syncEvent: &tencentim.RoomEvent{
// 同步广播只选 GiftSent 主事件作为客户端房间系统消息Heat/Rank 通过字段携带。
EventID: giftEvent.EventID,
@ -158,6 +176,32 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
}, nil
}
func normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw)
switch raw {
case "", "user", "all_mic", "all_room", "couple":
return raw
default:
return raw
}
}
func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 {
seen := make(map[int64]bool, len(targetUserIDs)+1)
ids := make([]int64, 0, len(targetUserIDs)+1)
for _, userID := range targetUserIDs {
if userID <= 0 || seen[userID] {
continue
}
seen[userID] = true
ids = append(ids, userID)
}
if len(ids) == 0 && targetUserID > 0 {
ids = append(ids, targetUserID)
}
return ids
}
func findRankItem(items []state.RankItem, userID int64) state.RankItem {
// 送礼后通常能找到发送方榜单项;找不到时返回 user_id 占位避免空事件。
for _, item := range items {

View File

@ -281,6 +281,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
current.Version++
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
@ -429,6 +430,94 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
}, nil
}
// SetMicMute 修改麦位上的服务端可见静音态。
func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
cmd := command.SetMicMute{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
Muted: req.GetMuted(),
}
if cmd.TargetUserID == 0 {
cmd.TargetUserID = cmd.ActorUserID()
}
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if cmd.TargetUserID <= 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
return mutationResult{}, nil, err
}
seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
}
if seat.MicMuted == cmd.Muted {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].MicMuted = cmd.Muted
current.Version++
action := "mic_unmute"
if cmd.Muted {
action = "mic_mute"
}
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: seat.SeatNo,
ToSeat: seat.SeatNo,
Action: action,
MicSessionId: seat.MicSessionID,
PublishState: seat.PublishState,
PublishDeadlineMs: seat.PublishDeadlineMS,
MicMuted: cmd.Muted,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_mute_changed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
"action": action,
"mic_session_id": seat.MicSessionID,
"publish_state": seat.PublishState,
"mic_muted": boolString(cmd.Muted),
},
},
}, []outbox.Record{micEvent}, nil
})
if err != nil {
return nil, err
}
return &roomv1.SetMicMuteResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
}, nil
}
func micSessionIDForCommand(roomID string, commandID string) string {
sum := sha256.Sum256([]byte(roomID + "\x00" + commandID))
return "mic_" + hex.EncodeToString(sum[:16])

View File

@ -0,0 +1,76 @@
package service
import (
"context"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
)
const (
defaultRoomOnlineUsersPageSize = 50
maxRoomOnlineUsersPageSize = 100
)
// ListRoomOnlineUsers 分页读取房间在线用户列表。
// 该接口用于右上角在线人数弹窗,不能让客户端用完整 RoomSnapshot 做分页。
func (s *Service) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
roomID := req.GetRoomId()
viewerUserID := req.GetViewerUserId()
if !roomid.ValidStringID(roomID) {
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
if viewerUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, viewerUserID)
if err != nil {
return nil, err
}
if !exists || presence.RoomID != roomID || presence.Status != roomPresenceStatusActive {
return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
}
page := int(req.GetPage())
if page <= 0 {
page = 1
}
pageSize := int(req.GetPageSize())
if pageSize <= 0 {
pageSize = defaultRoomOnlineUsersPageSize
}
if pageSize > maxRoomOnlineUsersPageSize {
pageSize = maxRoomOnlineUsersPageSize
}
result, err := s.repository.ListRoomOnlineUsers(ctx, RoomOnlineUserQuery{
AppCode: appcode.FromContext(ctx),
RoomID: roomID,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, err
}
users := make([]*roomv1.RoomUser, 0, len(result.Users))
for _, user := range result.Users {
users = append(users, &roomv1.RoomUser{
UserId: user.UserID,
Role: user.Role,
LastSeenAtMs: user.LastSeenAtMS,
})
}
return &roomv1.ListRoomOnlineUsersResponse{
Users: users,
Total: result.Total,
Page: int32(result.Page),
PageSize: int32(result.PageSize),
ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}

View File

@ -9,11 +9,12 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/room/outbox"
)
const (
// outboxRetryFixedInterval 是 V1 唯一支持的补偿策略,失败记录下一轮继续扫描
outboxRetryFixedInterval = "fixed_interval"
// outboxRetryExponentialBackoff 是房间事件补偿的默认策略,避免外部故障时每秒刷同一条错误
outboxRetryExponentialBackoff = "exponential_backoff"
// outboxWorkerName 固定写入日志,便于后续按 worker 聚合检索。
outboxWorkerName = "room_outbox"
)
@ -26,17 +27,26 @@ type OutboxWorkerOptions struct {
BatchSize int
// PublishTimeout 限制单条外部投递耗时shutdown 时当前事件最多阻塞该时长。
PublishTimeout time.Duration
// RetryStrategy 当前只支持 fixed_interval
// RetryStrategy 当前只支持 exponential_backoff
RetryStrategy string
// MaxRetryCount 是转入 failed 前允许的最大失败次数。
MaxRetryCount int
// InitialBackoff 是首次失败后的等待时间。
InitialBackoff time.Duration
// MaxBackoff 是指数退避上限。
MaxBackoff time.Duration
}
func defaultOutboxWorkerOptions() OutboxWorkerOptions {
// 默认值对应文档里的 V1 固定间隔补偿策略,优先避免 busy loop 和无限外部调用。
// 默认值优先避免 busy loop、无限重试和日志污染
return OutboxWorkerOptions{
PollInterval: time.Second,
BatchSize: 100,
PublishTimeout: 3 * time.Second,
RetryStrategy: outboxRetryFixedInterval,
RetryStrategy: outboxRetryExponentialBackoff,
MaxRetryCount: 10,
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
}
}
@ -55,9 +65,21 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
// 单条外部投递必须有 deadlineshutdown 才能等待有界。
options.PublishTimeout = defaults.PublishTimeout
}
if options.MaxRetryCount <= 0 {
options.MaxRetryCount = defaults.MaxRetryCount
}
if options.InitialBackoff <= 0 {
options.InitialBackoff = defaults.InitialBackoff
}
if options.MaxBackoff <= 0 {
options.MaxBackoff = defaults.MaxBackoff
}
if options.MaxBackoff < options.InitialBackoff {
options.MaxBackoff = options.InitialBackoff
}
options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
if options.RetryStrategy == "" {
// 空策略按 fixed_interval 处理,保持和配置默认值一致。
// 空策略按指数退避处理,保持和配置默认值一致。
options.RetryStrategy = defaults.RetryStrategy
}
@ -67,7 +89,7 @@ func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptio
// RunOutboxWorker 持续扫描 pending outbox它不执行房间命令只投递已持久化事件。
func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptions) {
options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryFixedInterval {
if options.RetryStrategy != outboxRetryExponentialBackoff {
// 配置层会拒绝未知策略;这里兜底避免测试或手写构造静默改变语义。
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID))
return
@ -95,7 +117,7 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
// ProcessPendingOutbox 把待投递事件补偿性地推送给异步消费者。
func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorkerOptions) error {
options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryFixedInterval {
if options.RetryStrategy != outboxRetryExponentialBackoff {
return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy)
}
@ -111,14 +133,34 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return ctx.Err()
default:
}
if record.RetryCount >= options.MaxRetryCount {
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := s.repository.MarkOutboxDead(markCtx, record.EventID, deadLetterReason(record))
markCancel()
if markErr != nil {
return markErr
}
logx.Error(ctx, "outbox_dead_letter", nil,
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount),
slog.String("last_error", trimOutboxError(record.LastError)),
)
continue
}
publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout)
err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
cancel()
if err != nil {
// 投递失败转为 retryable并记录错误和重试次数等待下一轮 worker 再抢占。
// 投递失败转为 retryable并写入指数退避时间等待下一轮 worker 到期后再抢占。
nextRetryAtMS := time.Now().UTC().Add(outboxBackoff(record.RetryCount+1, options)).UnixMilli()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := s.repository.MarkOutboxFailed(markCtx, record.EventID, trimOutboxError(err.Error()))
markErr := s.repository.MarkOutboxRetryable(markCtx, record.EventID, trimOutboxError(err.Error()), nextRetryAtMS)
markCancel()
if markErr != nil {
return markErr
@ -131,6 +173,7 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount+1),
slog.Int64("next_retry_at_ms", nextRetryAtMS),
slog.String("error", trimOutboxError(err.Error())),
)
continue
@ -157,6 +200,30 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return nil
}
func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration {
if retryCount <= 1 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
if backoff >= options.MaxBackoff/2 {
return options.MaxBackoff
}
backoff *= 2
}
if backoff > options.MaxBackoff {
return options.MaxBackoff
}
return backoff
}
func deadLetterReason(record outbox.Record) string {
if strings.TrimSpace(record.LastError) == "" {
return "outbox retry limit exceeded"
}
return trimOutboxError("outbox retry limit exceeded: " + record.LastError)
}
func trimOutboxError(value string) string {
// last_error 会写入 MySQL 和日志,先去掉首尾空白避免不可见差异影响排障。
value = strings.TrimSpace(value)

View File

@ -3,11 +3,9 @@ package service
import (
"bytes"
"context"
"log/slog"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
@ -17,6 +15,12 @@ import (
)
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
startedAt := time.Now()
var idempotencyMS int64
var leaseMS int64
var saveMutationMS int64
var snapshotMS int64
var projectionMS int64
if cmd.RoomID() == "" || cmd.ID() == "" || cmd.ActorUserID() == 0 {
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
@ -26,7 +30,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return mutationResult{}, err
}
@ -41,7 +47,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
// 确保当前节点持有房间 lease并在没有内存 Cell 时完成恢复。
leaseStartedAt := time.Now()
roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID())
leaseMS = elapsedMS(leaseStartedAt)
if err != nil {
return mutationResult{}, err
}
@ -69,6 +77,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
@ -88,7 +97,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}); err != nil {
return nil, err
}
// 保存已提交 outbox 记录,出 Cell 锁后再通知 activity-service外部服务慢调用不能占住房间串行执行队列。
saveMutationMS += elapsedMS(saveStartedAt)
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity避免外部慢调用占住房间串行执行队列。
result.outboxRecords = outboxRecords
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
@ -102,6 +112,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
@ -118,6 +129,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}); err != nil {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
}
return result, nil
@ -130,61 +142,26 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
if result.applied && shouldSnapshot(result.snapshot, s.snapshotEveryN) {
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
snapshotStartedAt := time.Now()
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
return mutationResult{}, err
}
snapshotMS = elapsedMS(snapshotStartedAt)
}
if result.applied {
projectionStartedAt := time.Now()
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
s.projectRoomListBestEffort(ctx, result.snapshot)
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
// activity-service 消费的是已经落库的 room outbox 事实失败只记日志outbox 补偿链路仍保留房间 IM 投递能力。
s.publishRoomEventsBestEffort(ctx, result.outboxRecords)
projectionMS = elapsedMS(projectionStartedAt)
}
if result.applied && result.syncEvent != nil {
// 同步腾讯云 IM 失败不回滚房间状态outbox 后续负责补偿投递。
if err := s.syncPublisher.PublishRoomEvent(ctx, *result.syncEvent); err == nil {
// 同步投递成功后 best-effort 标记 delivered避免补偿 worker 对同一 event_id 重复补投。
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), defaultOutboxWorkerOptions().PublishTimeout)
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
cancel()
if markErr != nil {
logx.Error(ctx, "outbox_mark_delivered_failed", markErr,
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", result.syncEvent.EventID),
slog.String("room_id", result.syncEvent.RoomID),
)
}
}
}
logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), result.applied, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, result.walletDebitMS, 0, 0, len(result.outboxRecords) > 0, result.syncEvent != nil)
return result, nil
}
func (s *Service) publishRoomEventsBestEffort(ctx context.Context, records []outbox.Record) {
if s == nil || s.eventPublisher == nil || len(records) == 0 {
return
}
for _, record := range records {
if record.Envelope == nil {
// 只有 protobuf envelope 能跨服务消费;没有 envelope 的历史/本地事件仍只参与原 outbox 投递。
continue
}
if err := s.eventPublisher.PublishOutboxEvent(ctx, record.Envelope); err != nil {
logx.Warn(ctx, "room_event_consumer_publish_failed",
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.String("error", err.Error()),
)
}
}
}
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
payload, err := command.Serialize(cmd)
if err != nil {
@ -230,3 +207,7 @@ func (s *Service) ensureLeaseStillOwned(ctx context.Context, roomID string, leas
}
return nil
}
func elapsedMS(start time.Time) int64 {
return time.Since(start).Milliseconds()
}

View File

@ -61,7 +61,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}
current.Version++
// JoinRoom 成功需要 outbox 事件,同时同步推给腾讯云 IM 房间系统消息。
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, &roomeventsv1.RoomUserJoined{
UserId: cmd.ActorUserID(),
Role: cmd.Role,
@ -215,7 +215,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
records = append(records, micEvent)
}
// 离房事件进入 outbox,并尝试同步通知腾讯云 IMstale 清理会通过 attributes 标记原因。
// 离房事件进入 outboxstale 清理会通过 attributes 标记原因,腾讯云 IM 由 worker 异步投递
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
UserId: cmd.ActorUserID(),
})

View File

@ -79,11 +79,13 @@ func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.Ro
}
recovered = state.FromProto(&snapshot)
recovered.VisibleRegionID = roomMeta.VisibleRegionID
fromVersion = snapshot.GetVersion()
} else {
// 没有快照时从 rooms meta 重建初始状态,再从版本 0 回放命令。
recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.HostUserID, roomMeta.SeatCount, roomMeta.Mode)
recovered.Status = roomMeta.Status
recovered.VisibleRegionID = roomMeta.VisibleRegionID
fromVersion = 0
}
@ -117,6 +119,7 @@ func replay(current *state.RoomState, cmd command.Command) error {
// replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。
switch typed := cmd.(type) {
case *command.CreateRoom:
current.VisibleRegionID = typed.VisibleRegionID
// 无快照恢复时,创建命令负责把列表/详情展示资料还原到 RoomExt。
applyRoomProfileExt(current, roomProfileInput{
Name: typed.RoomName,
@ -255,6 +258,15 @@ func replay(current *state.RoomState, cmd command.Command) error {
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
current.Version++
case *command.SetMicMute:
seat, exists := current.SeatByUser(typed.TargetUserID)
if !exists {
return fmt.Errorf("set_mic_mute replay target is not on seat: %d", typed.TargetUserID)
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].MicMuted = typed.Muted
current.Version++
case *command.SetMicSeatLock:
index := current.SeatIndex(typed.SeatNo)

View File

@ -156,6 +156,22 @@ type RoomPresence struct {
UpdatedAtMS int64
}
// RoomOnlineUserQuery 是房间在线用户分页读模型查询条件。
type RoomOnlineUserQuery struct {
AppCode string
RoomID string
Page int
PageSize int
}
// RoomOnlineUserPage 是 room_user_presence 中某房间 active 用户的一页结果。
type RoomOnlineUserPage struct {
Users []RoomPresence
Total int64
Page int
PageSize int
}
// RoomListQuery 是 room-service 房间列表读模型查询条件。
type RoomListQuery struct {
// AppCode 是列表查询所属 App通常来自 gateway RequestMeta。
@ -278,8 +294,10 @@ type Repository interface {
ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]outbox.Record, error)
// MarkOutboxDelivered 标记事件已经投递成功。
MarkOutboxDelivered(ctx context.Context, eventID string) error
// MarkOutboxFailed 记录一次投递失败并转为 retryable 状态等待重试。
MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error
// MarkOutboxRetryable 记录一次投递失败,并写入下一次允许抢占的退避时间。
MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error
// MarkOutboxDead 把超过最大重试次数的事件转入 failed避免固定污染日志和外部依赖。
MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error
// UpsertRoomListEntry 写入或更新房间列表读模型;失败不应破坏 Room Cell 已提交状态。
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
// ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。
@ -294,6 +312,8 @@ type Repository interface {
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
// GetCurrentRoomPresence 查询用户当前 active 房间 presence不扫描房间列表或快照。
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
// ListRoomOnlineUsers 查询房间 active 在线用户分页,不读取完整 RoomSnapshot。
ListRoomOnlineUsers(ctx context.Context, query RoomOnlineUserQuery) (RoomOnlineUserPage, error)
}
// RoomPresenceSnapshot 是 repository 投影接口的稳定入参,避免存储层反向依赖 protobuf。

View File

@ -60,8 +60,6 @@ type Service struct {
syncPublisher integration.RoomEventPublisher
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
outboxPublisher integration.OutboxPublisher
// eventPublisher 是命令提交后的房间事实消费者投递通道,不参与 Room Cell 提交回滚。
eventPublisher integration.OutboxPublisher
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
draining atomic.Bool
@ -93,7 +91,7 @@ type mutationResult struct {
giftRank []*roomv1.RankItem
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte
// syncEvent 是需要同步推给腾讯云 IM 的低时延房间系统事件
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行
syncEvent *tencentim.RoomEvent
// outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。
outboxRecords []outbox.Record
@ -101,6 +99,8 @@ type mutationResult struct {
roomStatus string
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
roomSeatCount *int32
// walletDebitMS 记录 SendGift 同步扣费耗时;非钱包命令保持 0。
walletDebitMS int64
}
// New 初始化 room-service 领域服务。
@ -152,13 +152,6 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
}
}
// SetEventPublisher 注入 room outbox 事实消费者;失败只记录日志,不改变房间主链路。
func (s *Service) SetEventPublisher(publisher integration.OutboxPublisher) {
if s != nil {
s.eventPublisher = publisher
}
}
// HealthCheck 校验 room-service 核心状态 owner 和持久化依赖已经完成装配。
func (s *Service) HealthCheck() error {
if s == nil {

View File

@ -2244,7 +2244,7 @@ func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(t *testing.T) {
t.Fatalf("SaveOutbox failed: %v", err)
}
options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second}
options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond, MaxRetryCount: 3}
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err)
}
@ -2252,10 +2252,11 @@ func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(t *testing.T) {
if !exists {
t.Fatalf("outbox record missing after failure")
}
if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || failed.NextRetryAtMS <= time.Now().Add(-time.Second).UnixMilli() || !strings.Contains(failed.LastError, "temporary im failure") {
t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed)
}
time.Sleep(2 * time.Millisecond)
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
t.Fatalf("second ProcessPendingOutbox failed: %v", err)
}
@ -2301,8 +2302,8 @@ func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T)
t.Fatalf("worker-b should not claim worker-a locked record: %+v", claimedB)
}
if err := repository.MarkOutboxFailed(ctx, record.EventID, "temporary failure"); err != nil {
t.Fatalf("MarkOutboxFailed failed: %v", err)
if err := repository.MarkOutboxRetryable(ctx, record.EventID, "temporary failure", time.Now().UnixMilli()); err != nil {
t.Fatalf("MarkOutboxRetryable failed: %v", err)
}
claimedRetry, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli())
if err != nil {
@ -2313,7 +2314,40 @@ func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T)
}
}
func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
func TestOutboxWorkerMarksDeadLetterAfterRetryLimit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
outboxPublisher := &fakeOutboxPublisher{failCount: 10, err: errors.New("permanent im failure")}
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher)
record, err := outbox.Build("room-dead", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 1,
TargetUserId: 2,
GiftId: "rocket",
GiftCount: 1,
GiftValue: 100,
})
if err != nil {
t.Fatalf("Build outbox failed: %v", err)
}
record.RetryCount = 2
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
t.Fatalf("SaveOutbox failed: %v", err)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second, MaxRetryCount: 2}); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
dead, exists := repository.OutboxRecord(record.EventID)
if !exists || dead.Status != outbox.StatusFailed || !strings.Contains(dead.LastError, "retry limit") {
t.Fatalf("event should be dead-lettered: exists=%v record=%+v", exists, dead)
}
if len(outboxPublisher.envelopes) != 0 {
t.Fatalf("dead-lettered record must not be published again")
}
}
func TestRoomEventStaysPendingUntilOutboxWorkerDelivers(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
syncPublisher := &fakeSyncPublisher{}
@ -2333,17 +2367,23 @@ func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
if len(syncPublisher.events) == 0 {
t.Fatalf("expected sync room event")
if len(syncPublisher.events) != 0 {
t.Fatalf("room events must not be synchronously published: %+v", syncPublisher.events)
}
eventID := syncPublisher.events[len(syncPublisher.events)-1].EventID
record, exists := repository.OutboxRecord(eventID)
if !exists {
t.Fatalf("sync event outbox record missing: %s", eventID)
records, err := repository.ListPendingOutbox(ctx, 100)
if err != nil {
t.Fatalf("ListPendingOutbox failed: %v", err)
}
if record.Status != outbox.StatusDelivered {
t.Fatalf("sync success should best-effort mark outbox delivered: %+v", record)
if len(records) == 0 {
t.Fatalf("expected pending room event before worker delivery")
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil {
t.Fatalf("ProcessPendingOutbox failed: %v", err)
}
delivered, exists := repository.OutboxRecord(records[0].EventID)
if !exists || delivered.Status != outbox.StatusDelivered {
t.Fatalf("outbox worker should mark event delivered: exists=%v record=%+v", exists, delivered)
}
}

View File

@ -44,6 +44,8 @@ type MicSeat struct {
MicSessionRoomVersion int64
// LastPublishEventTimeMS 是已接受的最新 RTC/客户端发布事件时间。
LastPublishEventTimeMS int64
// MicMuted 是服务端可见的麦克风静音状态,用于同步其他用户 UI。
MicMuted bool
}
// RoomUserState 只保存 room-service 真正需要的轻量用户态。
@ -81,6 +83,8 @@ type RoomState struct {
HostUserID int64
// Mode 是房间模式,当前只作为状态字段透出。
Mode string
// VisibleRegionID 是房间礼物、列表和区域可见性策略使用的区域桶。
VisibleRegionID int64
// Status 是房间生命周期状态。
Status string
// ChatEnabled 控制腾讯云 IM 公屏发言守卫。
@ -143,21 +147,22 @@ func (s *RoomState) Clone() *RoomState {
// 所有 map 和 slice 都重新分配,避免 mutate 失败时污染当前 Cell 状态。
cloned := &RoomState{
RoomID: s.RoomID,
OwnerUserID: s.OwnerUserID,
HostUserID: s.HostUserID,
Mode: s.Mode,
Status: s.Status,
ChatEnabled: s.ChatEnabled,
MicSeats: append([]MicSeat(nil), s.MicSeats...),
OnlineUsers: make(map[int64]*RoomUserState, len(s.OnlineUsers)),
AdminUsers: make(map[int64]bool, len(s.AdminUsers)),
BanUsers: make(map[int64]bool, len(s.BanUsers)),
MuteUsers: make(map[int64]bool, len(s.MuteUsers)),
Heat: s.Heat,
GiftRank: append([]RankItem(nil), s.GiftRank...),
RoomExt: make(map[string]string, len(s.RoomExt)),
Version: s.Version,
RoomID: s.RoomID,
OwnerUserID: s.OwnerUserID,
HostUserID: s.HostUserID,
Mode: s.Mode,
VisibleRegionID: s.VisibleRegionID,
Status: s.Status,
ChatEnabled: s.ChatEnabled,
MicSeats: append([]MicSeat(nil), s.MicSeats...),
OnlineUsers: make(map[int64]*RoomUserState, len(s.OnlineUsers)),
AdminUsers: make(map[int64]bool, len(s.AdminUsers)),
BanUsers: make(map[int64]bool, len(s.BanUsers)),
MuteUsers: make(map[int64]bool, len(s.MuteUsers)),
Heat: s.Heat,
GiftRank: append([]RankItem(nil), s.GiftRank...),
RoomExt: make(map[string]string, len(s.RoomExt)),
Version: s.Version,
}
for userID, userState := range s.OnlineUsers {
@ -263,6 +268,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
PublishDeadlineMs: seat.PublishDeadlineMS,
MicSessionRoomVersion: seat.MicSessionRoomVersion,
LastPublishEventTimeMs: seat.LastPublishEventTimeMS,
MicMuted: seat.MicMuted,
})
}
@ -279,22 +285,23 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
}
return &roomv1.RoomSnapshot{
RoomId: s.RoomID,
OwnerUserId: s.OwnerUserID,
HostUserId: s.HostUserID,
Mode: s.Mode,
Status: s.Status,
ChatEnabled: s.ChatEnabled,
MicSeats: seats,
OnlineUsers: users,
AdminUserIds: sortedSetIDs(s.AdminUsers),
BanUserIds: sortedSetIDs(s.BanUsers),
MuteUserIds: sortedSetIDs(s.MuteUsers),
GiftRank: rankItems,
RoomExt: cloneStringMap(s.RoomExt),
Heat: s.Heat,
Version: s.Version,
RoomShortId: s.RoomExt["room_short_id"],
RoomId: s.RoomID,
OwnerUserId: s.OwnerUserID,
HostUserId: s.HostUserID,
Mode: s.Mode,
VisibleRegionId: s.VisibleRegionID,
Status: s.Status,
ChatEnabled: s.ChatEnabled,
MicSeats: seats,
OnlineUsers: users,
AdminUserIds: sortedSetIDs(s.AdminUsers),
BanUserIds: sortedSetIDs(s.BanUsers),
MuteUserIds: sortedSetIDs(s.MuteUsers),
GiftRank: rankItems,
RoomExt: cloneStringMap(s.RoomExt),
Heat: s.Heat,
Version: s.Version,
RoomShortId: s.RoomExt["room_short_id"],
}
}
@ -307,21 +314,22 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
// 所有集合重新分配,恢复后的状态可以直接交给 Room Cell 独占使用。
restored := &RoomState{
RoomID: snapshot.GetRoomId(),
OwnerUserID: snapshot.GetOwnerUserId(),
HostUserID: snapshot.GetHostUserId(),
Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(),
ChatEnabled: snapshot.GetChatEnabled(),
MicSeats: make([]MicSeat, 0, len(snapshot.GetMicSeats())),
OnlineUsers: make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())),
AdminUsers: make(map[int64]bool, len(snapshot.GetAdminUserIds())),
BanUsers: make(map[int64]bool, len(snapshot.GetBanUserIds())),
MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
RoomExt: cloneStringMap(snapshot.GetRoomExt()),
Heat: snapshot.GetHeat(),
Version: snapshot.GetVersion(),
RoomID: snapshot.GetRoomId(),
OwnerUserID: snapshot.GetOwnerUserId(),
HostUserID: snapshot.GetHostUserId(),
Mode: snapshot.GetMode(),
VisibleRegionID: snapshot.GetVisibleRegionId(),
Status: snapshot.GetStatus(),
ChatEnabled: snapshot.GetChatEnabled(),
MicSeats: make([]MicSeat, 0, len(snapshot.GetMicSeats())),
OnlineUsers: make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())),
AdminUsers: make(map[int64]bool, len(snapshot.GetAdminUserIds())),
BanUsers: make(map[int64]bool, len(snapshot.GetBanUserIds())),
MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
RoomExt: cloneStringMap(snapshot.GetRoomExt()),
Heat: snapshot.GetHeat(),
Version: snapshot.GetVersion(),
}
for _, seat := range snapshot.GetMicSeats() {
@ -335,6 +343,7 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
MicSessionRoomVersion: seat.GetMicSessionRoomVersion(),
LastPublishEventTimeMS: seat.GetLastPublishEventTimeMs(),
MicMuted: seat.GetMicMuted(),
})
}

View File

@ -163,10 +163,11 @@ func (r *Repository) Migrate(ctx context.Context) error {
envelope LONGBLOB NOT NULL,
created_at_ms BIGINT NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at_ms BIGINT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_room_outbox_pending (app_code, status, created_at_ms),
KEY idx_room_outbox_pending (app_code, status, next_retry_at_ms, created_at_ms),
KEY idx_room_outbox_claim (app_code, status, lock_until_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_feed_entries (
@ -206,9 +207,51 @@ func (r *Repository) Migrate(ctx context.Context) error {
}
}
return r.ensureOutboxRetrySchema(ctx)
}
func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms")
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD COLUMN next_retry_at_ms BIGINT NOT NULL DEFAULT 0 AFTER retry_count`); err != nil {
return err
}
}
hasIndex, err := r.indexExists(ctx, "room_outbox", "idx_room_outbox_retry")
if err != nil {
return err
}
if !hasIndex {
if _, err := r.db.ExecContext(ctx, `ALTER TABLE room_outbox ADD INDEX idx_room_outbox_retry (app_code, status, next_retry_at_ms, created_at_ms)`); err != nil {
return err
}
}
return nil
}
func (r *Repository) columnExists(ctx context.Context, tableName string, columnName string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
`, tableName, columnName).Scan(&exists)
return exists > 0, err
}
func (r *Repository) indexExists(ctx context.Context, tableName string, indexName string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
`, tableName, indexName).Scan(&exists)
return exists > 0, err
}
// SaveRoomMeta 插入房间元数据。
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
// rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。
@ -368,8 +411,8 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
@ -379,6 +422,7 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
@ -603,8 +647,8 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
@ -614,6 +658,7 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
@ -635,15 +680,19 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
}
// 按创建时间顺序扫描 pending尽量保持事件投递顺序。
nowMS := time.Now().UTC().UnixMilli()
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, COALESCE(last_error, '')
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
WHERE app_code = ? AND status IN (?, ?)
AND (status = ? OR next_retry_at_ms <= ?)
ORDER BY created_at_ms ASC
LIMIT ?`,
appcode.FromContext(ctx),
outbox.StatusPending,
outbox.StatusRetryable,
outbox.StatusPending,
nowMS,
limit,
)
if err != nil {
@ -657,7 +706,7 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
var envelopeBytes []byte
var record outbox.Record
var lockUntil sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
return nil, err
}
if lockUntil.Valid {
@ -698,11 +747,12 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
nowMS := time.Now().UTC().UnixMilli()
rows, err := tx.QueryContext(ctx,
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, COALESCE(last_error, '')
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
WHERE app_code = ?
AND (
status IN (?, ?)
status = ?
OR (status = ? AND next_retry_at_ms <= ?)
OR (status = ? AND lock_until_ms IS NOT NULL AND lock_until_ms <= ?)
)
ORDER BY created_at_ms ASC
@ -711,6 +761,7 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
appcode.FromContext(ctx),
outbox.StatusPending,
outbox.StatusRetryable,
nowMS,
outbox.StatusDelivering,
nowMS,
limit,
@ -726,7 +777,7 @@ func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, li
var envelopeBytes []byte
var record outbox.Record
var lockUntilValue sql.Null[int64]
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
@ -802,14 +853,31 @@ func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) er
return err
}
// MarkOutboxFailed 标记事件投递失败并累计重试次数
func (r *Repository) MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error {
// 失败转入 retryableworker 下一轮可重新抢占。
// MarkOutboxRetryable 标记事件投递失败并写入下一次重试时间
func (r *Repository) MarkOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
// 失败转入 retryableworker 只有到 next_retry_at_ms 后才会重新抢占。
_, err := r.db.ExecContext(ctx,
`UPDATE room_outbox
SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, last_error = ?, updated_at_ms = ?
SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusRetryable,
nextRetryAtMS,
lastErr,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
eventID,
)
return err
}
// MarkOutboxDead 把超过最大重试次数的事件转入 failed。
func (r *Repository) MarkOutboxDead(ctx context.Context, eventID string, lastErr string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE room_outbox
SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
WHERE app_code = ? AND event_id = ?`,
outbox.StatusFailed,
lastErr,
time.Now().UTC().UnixMilli(),
appcode.FromContext(ctx),
@ -1270,6 +1338,79 @@ func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (
return presence, true, nil
}
// ListRoomOnlineUsers 分页查询某个房间当前 active presence。
func (r *Repository) ListRoomOnlineUsers(ctx context.Context, query roomservice.RoomOnlineUserQuery) (roomservice.RoomOnlineUserPage, error) {
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = 50
}
offset := (page - 1) * pageSize
appCode := normalizedRecordAppCode(ctx, query.AppCode)
var total int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*)
FROM room_user_presence
WHERE app_code = ? AND room_id = ? AND status = ?`,
appCode,
query.RoomID,
roomPresenceStatusActiveSQL,
).Scan(&total); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
FROM room_user_presence
WHERE app_code = ? AND room_id = ? AND status = ?
ORDER BY last_seen_at_ms DESC, user_id ASC
LIMIT ? OFFSET ?`,
appCode,
query.RoomID,
roomPresenceStatusActiveSQL,
pageSize,
offset,
)
if err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
defer rows.Close()
users := make([]roomservice.RoomPresence, 0, pageSize)
for rows.Next() {
var presence roomservice.RoomPresence
if err := rows.Scan(
&presence.AppCode,
&presence.UserID,
&presence.RoomID,
&presence.Role,
&presence.PublishState,
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
users = append(users, presence)
}
if err := rows.Err(); err != nil {
return roomservice.RoomOnlineUserPage{}, err
}
return roomservice.RoomOnlineUserPage{
Users: users,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
const (
roomPresenceStatusActiveSQL = "active"
roomPresenceStatusLeftSQL = "left"

View File

@ -77,7 +77,7 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Helper()
row := r.schema.DB.QueryRowContext(context.Background(), `
SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, COALESCE(last_error, '')
SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until_ms, envelope, created_at_ms, retry_count, next_retry_at_ms, COALESCE(last_error, '')
FROM room_outbox
WHERE app_code = ? AND event_id = ?
`, appcode.Default, eventID)
@ -85,7 +85,7 @@ func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
var envelopeBytes []byte
var record outbox.Record
var lockUntil sql.Null[int64]
if err := row.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.LastError); err != nil {
if err := row.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAtMS, &record.RetryCount, &record.NextRetryAtMS, &record.LastError); err != nil {
if err == sql.ErrNoRows {
return outbox.Record{}, false
}

View File

@ -110,6 +110,13 @@ func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMi
return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req))
}
// SetMicMute 代理到领域服务。
func (s *Server) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
// 服务端可见静音态必须经 Room Cell 提交,确保其他用户 UI 和系统消息一致。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.SetMicMute(ctx, req))
}
// ApplyRTCEvent 代理到领域服务。
func (s *Server) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
// 腾讯 RTC 回调已在 gateway 验签;房间状态变化仍必须由 Room Cell 串行提交。
@ -222,6 +229,13 @@ func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapsho
return mapServiceResult(s.svc.GetRoomSnapshot(ctx, req))
}
// ListRoomOnlineUsers 代理到房间在线用户分页读模型。
func (s *Server) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
// 在线人数弹窗只读 room_user_presence 投影,不拉完整 RoomSnapshot 做分页。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.ListRoomOnlineUsers(ctx, req))
}
func contextWithMetaApp(ctx context.Context, meta *roomv1.RequestMeta) context.Context {
if meta == nil {
return appcode.WithContext(ctx, "")

View File

@ -28,6 +28,15 @@ const (
VipStatusActive = "active"
// VipStatusDisabled 表示 VIP 配置被后台停用。
VipStatusDisabled = "disabled"
// GameOpDebit 表示游戏平台下注、局内道具等扣金币行为。
GameOpDebit = "debit"
// GameOpCredit 表示游戏平台中奖、派奖等加金币行为。
GameOpCredit = "credit"
// GameOpRefund 表示游戏平台扣款失败或取消后的退款入账。
GameOpRefund = "refund"
// GameOpReverse 表示平台冲正;首版按扣回用户 COIN 处理。
GameOpReverse = "reverse"
)
// DebitGiftCommand 是 room-service 送礼扣费的账务命令。
@ -331,6 +340,28 @@ type TaskRewardReceipt struct {
GrantedAtMS int64
}
// GameCoinChangeCommand 是 game-service 对钱包发起的游戏专用金币改账命令。
type GameCoinChangeCommand struct {
AppCode string
CommandID string
UserID int64
PlatformCode string
GameID string
ProviderOrderID string
ProviderRoundID string
OpType string
CoinAmount int64
RoomID string
RequestHash string
}
// GameCoinChangeReceipt 是游戏改账成功或幂等重放后的稳定回执。
type GameCoinChangeReceipt struct {
TransactionID string
BalanceAfter int64
IdempotentReplay bool
}
// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。
func ValidAssetType(assetType string) bool {
switch assetType {
@ -369,3 +400,18 @@ func NormalizeCoinSellerStockType(stockType string) string {
return ""
}
}
func NormalizeGameOpType(opType string) string {
switch strings.ToLower(strings.TrimSpace(opType)) {
case GameOpDebit:
return GameOpDebit
case GameOpCredit:
return GameOpCredit
case GameOpRefund:
return GameOpRefund
case GameOpReverse:
return GameOpReverse
default:
return ""
}
}

View File

@ -18,6 +18,7 @@ type Repository interface {
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error)
ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error)
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error)
GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error)
@ -219,6 +220,36 @@ func (s *Service) CreditTaskReward(ctx context.Context, command ledger.TaskRewar
return s.repository.CreditTaskReward(ctx, command)
}
// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。
func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game coin command is incomplete")
}
command.OpType = ledger.NormalizeGameOpType(command.OpType)
if command.OpType == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
if command.CoinAmount <= 0 {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
}
command.RequestHash = strings.TrimSpace(command.RequestHash)
if command.RequestHash == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "request_hash is required")
}
if s.repository == nil {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.PlatformCode = strings.TrimSpace(command.PlatformCode)
command.GameID = strings.TrimSpace(command.GameID)
command.ProviderOrderID = strings.TrimSpace(command.ProviderOrderID)
command.ProviderRoundID = strings.TrimSpace(command.ProviderRoundID)
command.RoomID = strings.TrimSpace(command.RoomID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ApplyGameCoinChange(ctx, command)
}
// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。
func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) {
if s.repository == nil {

View File

@ -1223,6 +1223,111 @@ func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.
}
}
func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(88001, 500)
svc := walletservice.New(repository)
debit := ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-1",
UserID: 88001,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-1",
ProviderRoundID: "round-1",
OpType: ledger.GameOpDebit,
CoinAmount: 120,
RoomID: "room-game-1",
RequestHash: "hash-order-1",
}
first, err := svc.ApplyGameCoinChange(context.Background(), debit)
if err != nil {
t.Fatalf("game debit failed: %v", err)
}
if first.BalanceAfter != 380 || first.IdempotentReplay {
t.Fatalf("game debit receipt mismatch: %+v", first)
}
second, err := svc.ApplyGameCoinChange(context.Background(), debit)
if err != nil {
t.Fatalf("game debit replay failed: %v", err)
}
if second.TransactionID != first.TransactionID || !second.IdempotentReplay || second.BalanceAfter != 380 {
t.Fatalf("game debit replay mismatch: first=%+v second=%+v", first, second)
}
credit, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-2",
UserID: 88001,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-2",
OpType: ledger.GameOpCredit,
CoinAmount: 70,
RequestHash: "hash-order-2",
})
if err != nil {
t.Fatalf("game credit failed: %v", err)
}
if credit.BalanceAfter != 450 {
t.Fatalf("game credit balance mismatch: %+v", credit)
}
balances, err := svc.GetBalances(context.Background(), 88001, []string{ledger.AssetCoin})
if err != nil {
t.Fatalf("GetBalances failed: %v", err)
}
if balanceAmount(balances, ledger.AssetCoin) != 450 {
t.Fatalf("final balance mismatch: %+v", balances)
}
if got := repository.CountRows("wallet_transactions", "biz_type IN ('game_debit','game_credit')"); got != 2 {
t.Fatalf("game coin changes should write two transactions, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "event_type = ?", "WalletBalanceChanged"); got < 2 {
t.Fatalf("game coin changes should publish balance outbox events, got %d", got)
}
}
func TestApplyGameCoinChangeRejectsHashConflictAndInsufficientBalance(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(88002, 50)
svc := walletservice.New(repository)
command := ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-conflict",
UserID: 88002,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-conflict",
OpType: ledger.GameOpDebit,
CoinAmount: 20,
RequestHash: "hash-a",
}
if _, err := svc.ApplyGameCoinChange(context.Background(), command); err != nil {
t.Fatalf("first game debit failed: %v", err)
}
command.RequestHash = "hash-b"
if _, err := svc.ApplyGameCoinChange(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
}
_, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
AppCode: "lalu",
CommandID: "game:demo:order-insufficient",
UserID: 88002,
PlatformCode: "demo",
GameID: "demo_rocket_001",
ProviderOrderID: "order-insufficient",
OpType: ledger.GameOpDebit,
CoinAmount: 1000,
RequestHash: "hash-insufficient",
})
if !xerr.IsCode(err, xerr.InsufficientBalance) {
t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", "game:demo:order-insufficient"); got != 0 {
t.Fatalf("insufficient game debit must not write transaction, got %d", got)
}
}
func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
for _, balance := range balances {
if balance.AssetType == assetType {

View File

@ -28,6 +28,10 @@ const (
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
bizTypeGameDebit = "game_debit"
bizTypeGameCredit = "game_credit"
bizTypeGameRefund = "game_refund"
bizTypeGameReverse = "game_reverse"
outboxStatusPending = "pending"
)
@ -420,6 +424,92 @@ func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRe
return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}
// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。
func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if r == nil || r.db == nil {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.GameCoinChangeReceipt{}, err
}
receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID)
receipt.IdempotentReplay = true
return receipt, receiptErr
}
nowMs := time.Now().UnixMilli()
createIfMissing := delta > 0
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs)
if err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if delta < 0 && account.AvailableAmount < -delta {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
transactionID := transactionID(command.AppCode, command.CommandID)
balanceAfter := account.AvailableAmount + delta
metadata := gameCoinMetadata{
AppCode: command.AppCode,
UserID: command.UserID,
PlatformCode: command.PlatformCode,
GameID: command.GameID,
ProviderOrderID: command.ProviderOrderID,
ProviderRoundID: command.ProviderRoundID,
OpType: command.OpType,
AssetType: ledger.AssetCoin,
CoinAmount: command.CoinAmount,
AvailableDelta: delta,
RoomID: command.RoomID,
BalanceAfter: balanceAfter,
AppliedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: delta,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
}); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
return receiptFromGameMetadata(transactionID, metadata, false), nil
}
// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。
func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
if r == nil || r.db == nil {
@ -1126,6 +1216,22 @@ type taskRewardMetadata struct {
GrantedAtMS int64 `json:"granted_at_ms"`
}
type gameCoinMetadata struct {
AppCode string `json:"app_code"`
UserID int64 `json:"user_id"`
PlatformCode string `json:"platform_code"`
GameID string `json:"game_id"`
ProviderOrderID string `json:"provider_order_id"`
ProviderRoundID string `json:"provider_round_id"`
OpType string `json:"op_type"`
AssetType string `json:"asset_type"`
CoinAmount int64 `json:"coin_amount"`
AvailableDelta int64 `json:"available_delta"`
RoomID string `json:"room_id"`
BalanceAfter int64 `json:"balance_after"`
AppliedAtMS int64 `json:"applied_at_ms"`
}
type coinSellerTransferMetadata struct {
AppCode string `json:"app_code"`
SellerUserID int64 `json:"seller_user_id"`
@ -1214,6 +1320,32 @@ func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMeta
}
}
func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
).Scan(&metadataJSON); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
var metadata gameCoinMetadata
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return ledger.GameCoinChangeReceipt{}, err
}
return receiptFromGameMetadata(transactionID, metadata, false), nil
}
func receiptFromGameMetadata(transactionID string, metadata gameCoinMetadata, idempotentReplay bool) ledger.GameCoinChangeReceipt {
return ledger.GameCoinChangeReceipt{
TransactionID: transactionID,
BalanceAfter: metadata.BalanceAfter,
IdempotentReplay: idempotentReplay,
}
}
func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) {
row := tx.QueryRowContext(ctx,
`SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro,
@ -1497,6 +1629,22 @@ func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
))
}
func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) {
switch ledger.NormalizeGameOpType(opType) {
case ledger.GameOpDebit:
return bizTypeGameDebit, -coinAmount, nil
case ledger.GameOpCredit:
return bizTypeGameCredit, coinAmount, nil
case ledger.GameOpRefund:
return bizTypeGameRefund, coinAmount, nil
case ledger.GameOpReverse:
// 冲正首版按“从用户 COIN 扣回”处理;后续接入平台若语义不同,必须在 adapter 层拆成 debit/refund。
return bizTypeGameReverse, -coinAmount, nil
default:
return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
}
func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string {
return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%s|%d|%s|%d|%s|%s|%d|%s",
appcode.Normalize(command.AppCode),

Some files were not shown because too many files have changed in this diff Show More