增加房间页面 我的, 以及我的页 聚合

This commit is contained in:
zhx 2026-05-11 17:18:52 +08:00
parent a19857a4b5
commit b95224bff1
112 changed files with 15358 additions and 8044 deletions

View File

@ -15,7 +15,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/multi-app-tenant-architecture.md`。
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
协议边界:
@ -119,7 +119,7 @@ services/room-service/configs/config.tencent.example.yaml
基础闭环实现顺序见:
```text
docs/voice-room-basic-loop-implementation.md
docs/语音房基础闭环实现.md
```
## Storage Model

File diff suppressed because it is too large Load Diff

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.29.2
// - protoc v5.27.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.34.2
// protoc v5.29.2
// protoc-gen-go v1.35.1
// protoc v5.27.3
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -38,11 +38,9 @@ type EventEnvelope struct {
func (x *EventEnvelope) Reset() {
*x = EventEnvelope{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EventEnvelope) String() string {
@ -53,7 +51,7 @@ func (*EventEnvelope) ProtoMessage() {}
func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -135,11 +133,9 @@ type RoomCreated struct {
func (x *RoomCreated) Reset() {
*x = RoomCreated{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomCreated) String() string {
@ -150,7 +146,7 @@ func (*RoomCreated) ProtoMessage() {}
func (x *RoomCreated) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -226,11 +222,9 @@ type RoomUserJoined struct {
func (x *RoomUserJoined) Reset() {
*x = RoomUserJoined{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUserJoined) String() string {
@ -241,7 +235,7 @@ func (*RoomUserJoined) ProtoMessage() {}
func (x *RoomUserJoined) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -281,11 +275,9 @@ type RoomUserLeft struct {
func (x *RoomUserLeft) Reset() {
*x = RoomUserLeft{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUserLeft) String() string {
@ -296,7 +288,7 @@ func (*RoomUserLeft) ProtoMessage() {}
func (x *RoomUserLeft) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -330,11 +322,9 @@ type RoomClosed struct {
func (x *RoomClosed) Reset() {
*x = RoomClosed{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomClosed) String() string {
@ -345,7 +335,7 @@ func (*RoomClosed) ProtoMessage() {}
func (x *RoomClosed) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -397,11 +387,9 @@ type RoomMicChanged struct {
func (x *RoomMicChanged) Reset() {
*x = RoomMicChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomMicChanged) String() string {
@ -412,7 +400,7 @@ func (*RoomMicChanged) ProtoMessage() {}
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -510,11 +498,9 @@ type RoomMicSeatLocked struct {
func (x *RoomMicSeatLocked) Reset() {
*x = RoomMicSeatLocked{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomMicSeatLocked) String() string {
@ -525,7 +511,7 @@ func (*RoomMicSeatLocked) ProtoMessage() {}
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -573,11 +559,9 @@ type RoomChatEnabledChanged struct {
func (x *RoomChatEnabledChanged) Reset() {
*x = RoomChatEnabledChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomChatEnabledChanged) String() string {
@ -588,7 +572,7 @@ func (*RoomChatEnabledChanged) ProtoMessage() {}
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -630,11 +614,9 @@ type RoomAdminChanged struct {
func (x *RoomAdminChanged) Reset() {
*x = RoomAdminChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomAdminChanged) String() string {
@ -645,7 +627,7 @@ func (*RoomAdminChanged) ProtoMessage() {}
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -694,11 +676,9 @@ type RoomHostTransferred struct {
func (x *RoomHostTransferred) Reset() {
*x = RoomHostTransferred{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomHostTransferred) String() string {
@ -709,7 +689,7 @@ func (*RoomHostTransferred) ProtoMessage() {}
func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -758,11 +738,9 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUserMuted) String() string {
@ -773,7 +751,7 @@ func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -821,11 +799,9 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUserKicked) String() string {
@ -836,7 +812,7 @@ func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -877,11 +853,9 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUserUnbanned) String() string {
@ -892,7 +866,7 @@ func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -939,11 +913,9 @@ type RoomGiftSent struct {
func (x *RoomGiftSent) Reset() {
*x = RoomGiftSent{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomGiftSent) String() string {
@ -954,7 +926,7 @@ func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1037,11 +1009,9 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomHeatChanged) String() string {
@ -1052,7 +1022,7 @@ func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1094,11 +1064,9 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomRankChanged) String() string {
@ -1109,7 +1077,7 @@ func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1342,200 +1310,6 @@ func file_proto_events_room_v1_events_proto_init() {
if File_proto_events_room_v1_events_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_proto_events_room_v1_events_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*EventEnvelope); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*RoomCreated); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserJoined); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserLeft); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*RoomClosed); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RoomMicSeatLocked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*RoomChatEnabledChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*RoomAdminChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RoomHostTransferred); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserMuted); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[11].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserKicked); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[12].Exporter = func(v any, i int) any {
switch v := v.(*RoomUserUnbanned); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[13].Exporter = func(v any, i int) any {
switch v := v.(*RoomGiftSent); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[14].Exporter = func(v any, i int) any {
switch v := v.(*RoomHeatChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_events_room_v1_events_proto_msgTypes[15].Exporter = func(v any, i int) any {
switch v := v.(*RoomRankChanged); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

File diff suppressed because it is too large Load Diff

View File

@ -383,6 +383,18 @@ message ListRoomsRequest {
string query = 7;
}
// ListRoomFeedsRequest Mine visited/friend/following
//
message ListRoomFeedsRequest {
RequestMeta meta = 1;
int64 viewer_user_id = 2;
int64 visible_region_id = 3;
string tab = 4;
string cursor = 5;
int32 limit = 6;
string query = 7;
}
// RoomListItem Room Cell
message RoomListItem {
string room_id = 1;
@ -407,6 +419,21 @@ message ListRoomsResponse {
string next_cursor = 2;
}
// GetMyRoomRequest
// gateway owner_user_id ID
message GetMyRoomRequest {
RequestMeta meta = 1;
int64 owner_user_id = 2;
}
// GetMyRoomResponse Mine
// rooms Room Cell
message GetMyRoomResponse {
bool has_room = 1;
RoomListItem room = 2;
int64 server_time_ms = 3;
}
// GetCurrentRoomRequest presence
// gateway user_id user_id
message GetCurrentRoomRequest {
@ -473,6 +500,8 @@ service RoomGuardService {
// RoomQueryService Room Cell
service RoomQueryService {
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
}

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.29.2
// - protoc v5.27.3
// source: proto/room/v1/room.proto
package roomv1
@ -916,6 +916,8 @@ 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"
)
@ -927,6 +929,8 @@ const (
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceClient interface {
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
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)
}
@ -949,6 +953,26 @@ func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsReq
return out, nil
}
func (c *roomQueryServiceClient) ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomsResponse)
err := c.cc.Invoke(ctx, RoomQueryService_ListRoomFeeds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyRoomResponse)
err := c.cc.Invoke(ctx, RoomQueryService_GetMyRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetCurrentRoomResponse)
@ -976,6 +1000,8 @@ func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoo
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceServer interface {
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
mustEmbedUnimplementedRoomQueryServiceServer()
@ -991,6 +1017,12 @@ type UnimplementedRoomQueryServiceServer struct{}
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRooms not implemented")
}
func (UnimplementedRoomQueryServiceServer) ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoomFeeds not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyRoom not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCurrentRoom not implemented")
}
@ -1036,6 +1068,42 @@ func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, d
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_ListRoomFeeds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomFeedsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).ListRoomFeeds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_ListRoomFeeds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).ListRoomFeeds(ctx, req.(*ListRoomFeedsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_GetMyRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).GetMyRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_GetMyRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).GetMyRoom(ctx, req.(*GetMyRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_GetCurrentRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetCurrentRoomRequest)
if err := dec(in); err != nil {
@ -1083,6 +1151,14 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRooms",
Handler: _RoomQueryService_ListRooms_Handler,
},
{
MethodName: "ListRoomFeeds",
Handler: _RoomQueryService_ListRoomFeeds_Handler,
},
{
MethodName: "GetMyRoom",
Handler: _RoomQueryService_GetMyRoom_Handler,
},
{
MethodName: "GetCurrentRoom",
Handler: _RoomQueryService_GetCurrentRoom_Handler,

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v5.29.2
// protoc-gen-go v1.35.1
// protoc v5.27.3
// source: proto/user/v1/auth.proto
package userv1
@ -33,11 +33,9 @@ type LoginPasswordRequest struct {
func (x *LoginPasswordRequest) Reset() {
*x = LoginPasswordRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginPasswordRequest) String() string {
@ -48,7 +46,7 @@ func (*LoginPasswordRequest) ProtoMessage() {}
func (x *LoginPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -114,11 +112,9 @@ type LoginThirdPartyRequest struct {
func (x *LoginThirdPartyRequest) Reset() {
*x = LoginThirdPartyRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LoginThirdPartyRequest) String() string {
@ -129,7 +125,7 @@ func (*LoginThirdPartyRequest) ProtoMessage() {}
func (x *LoginThirdPartyRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -298,11 +294,9 @@ type AuthResponse struct {
func (x *AuthResponse) Reset() {
*x = AuthResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuthResponse) String() string {
@ -313,7 +307,7 @@ func (*AuthResponse) ProtoMessage() {}
func (x *AuthResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -369,11 +363,9 @@ type SetPasswordRequest struct {
func (x *SetPasswordRequest) Reset() {
*x = SetPasswordRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetPasswordRequest) String() string {
@ -384,7 +376,7 @@ func (*SetPasswordRequest) ProtoMessage() {}
func (x *SetPasswordRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -431,11 +423,9 @@ type SetPasswordResponse struct {
func (x *SetPasswordResponse) Reset() {
*x = SetPasswordResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetPasswordResponse) String() string {
@ -446,7 +436,7 @@ func (*SetPasswordResponse) ProtoMessage() {}
func (x *SetPasswordResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -480,11 +470,9 @@ type RefreshTokenRequest struct {
func (x *RefreshTokenRequest) Reset() {
*x = RefreshTokenRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RefreshTokenRequest) String() string {
@ -495,7 +483,7 @@ func (*RefreshTokenRequest) ProtoMessage() {}
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -535,11 +523,9 @@ type RefreshTokenResponse struct {
func (x *RefreshTokenResponse) Reset() {
*x = RefreshTokenResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RefreshTokenResponse) String() string {
@ -550,7 +536,7 @@ func (*RefreshTokenResponse) ProtoMessage() {}
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[6]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -585,11 +571,9 @@ type LogoutRequest struct {
func (x *LogoutRequest) Reset() {
*x = LogoutRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutRequest) String() string {
@ -600,7 +584,7 @@ func (*LogoutRequest) ProtoMessage() {}
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[7]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -647,11 +631,9 @@ type LogoutResponse struct {
func (x *LogoutResponse) Reset() {
*x = LogoutResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LogoutResponse) String() string {
@ -662,7 +644,7 @@ func (*LogoutResponse) ProtoMessage() {}
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[8]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -700,11 +682,9 @@ type RecordLoginBlockedRequest struct {
func (x *RecordLoginBlockedRequest) Reset() {
*x = RecordLoginBlockedRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordLoginBlockedRequest) String() string {
@ -715,7 +695,7 @@ func (*RecordLoginBlockedRequest) ProtoMessage() {}
func (x *RecordLoginBlockedRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[9]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -783,11 +763,9 @@ type RecordLoginBlockedResponse struct {
func (x *RecordLoginBlockedResponse) Reset() {
*x = RecordLoginBlockedResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RecordLoginBlockedResponse) String() string {
@ -798,7 +776,7 @@ func (*RecordLoginBlockedResponse) ProtoMessage() {}
func (x *RecordLoginBlockedResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[10]
if protoimpl.UnsafeEnabled && x != nil {
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
@ -1039,140 +1017,6 @@ func file_proto_user_v1_auth_proto_init() {
return
}
file_proto_user_v1_user_proto_init()
if !protoimpl.UnsafeEnabled {
file_proto_user_v1_auth_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*LoginPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*LoginThirdPartyRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*AuthResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[4].Exporter = func(v any, i int) any {
switch v := v.(*SetPasswordResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[5].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[6].Exporter = func(v any, i int) any {
switch v := v.(*RefreshTokenResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[7].Exporter = func(v any, i int) any {
switch v := v.(*LogoutRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[8].Exporter = func(v any, i int) any {
switch v := v.(*LogoutResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[9].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_proto_user_v1_auth_proto_msgTypes[10].Exporter = func(v any, i int) any {
switch v := v.(*RecordLoginBlockedResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{

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.29.2
// - protoc v5.27.3
// source: proto/user/v1/auth.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,23 @@ message CoinSellerProfile {
int64 updated_at_ms = 6;
}
// UserRoleSummary App 使
// hostAgencyBD user-service RPC gateway fanout
message UserRoleSummary {
int64 user_id = 1;
bool is_host = 2;
bool is_agency = 3;
bool is_bd = 4;
bool is_bd_leader = 5;
bool is_coin_seller = 6;
string host_status = 7;
int64 agency_id = 8;
int64 bd_id = 9;
string bd_status = 10;
string coin_seller_status = 11;
int64 pending_role_invitations = 12;
}
// AgencyMembership host Agency
message AgencyMembership {
int64 membership_id = 1;
@ -225,6 +242,15 @@ message GetCoinSellerProfileResponse {
CoinSellerProfile coin_seller_profile = 1;
}
message GetUserRoleSummaryRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetUserRoleSummaryResponse {
UserRoleSummary summary = 1;
}
message GetAgencyMembersRequest {
RequestMeta meta = 1;
int64 agency_id = 2;
@ -364,6 +390,7 @@ service UserHostService {
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
rpc GetAgencyApplications(GetAgencyApplicationsRequest) returns (GetAgencyApplicationsResponse);
}

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.29.2
// - protoc v5.27.3
// source: proto/user/v1/host.proto
package userv1
@ -29,6 +29,7 @@ const (
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
)
@ -49,6 +50,7 @@ type UserHostServiceClient interface {
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
GetAgencyApplications(ctx context.Context, in *GetAgencyApplicationsRequest, opts ...grpc.CallOption) (*GetAgencyApplicationsResponse, error)
}
@ -161,6 +163,16 @@ func (c *userHostServiceClient) GetCoinSellerProfile(ctx context.Context, in *Ge
return out, nil
}
func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserRoleSummaryResponse)
err := c.cc.Invoke(ctx, UserHostService_GetUserRoleSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetAgencyMembersResponse)
@ -197,6 +209,7 @@ type UserHostServiceServer interface {
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
GetAgencyApplications(context.Context, *GetAgencyApplicationsRequest) (*GetAgencyApplicationsResponse, error)
mustEmbedUnimplementedUserHostServiceServer()
@ -239,6 +252,9 @@ func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDPr
func (UnimplementedUserHostServiceServer) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCoinSellerProfile not implemented")
}
func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserRoleSummary not implemented")
}
func (UnimplementedUserHostServiceServer) GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetAgencyMembers not implemented")
}
@ -446,6 +462,24 @@ func _UserHostService_GetCoinSellerProfile_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRoleSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).GetUserRoleSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_GetUserRoleSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).GetUserRoleSummary(ctx, req.(*GetUserRoleSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetAgencyMembers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetAgencyMembersRequest)
if err := dec(in); err != nil {
@ -529,6 +563,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetCoinSellerProfile",
Handler: _UserHostService_GetCoinSellerProfile_Handler,
},
{
MethodName: "GetUserRoleSummary",
Handler: _UserHostService_GetUserRoleSummary_Handler,
},
{
MethodName: "GetAgencyMembers",
Handler: _UserHostService_GetAgencyMembers_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -171,6 +171,166 @@ message GetUserResponse {
User user = 1;
}
// UserProfileStats read model
message UserProfileStats {
int64 user_id = 1;
int64 visitors_count = 2;
int64 following_count = 3;
int64 friends_count = 4;
int64 updated_at_ms = 5;
string app_code = 6;
}
// GetMyProfileStatsRequest
message GetMyProfileStatsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message GetMyProfileStatsResponse {
UserProfileStats stats = 1;
}
message RecordProfileVisitRequest {
RequestMeta meta = 1;
int64 visitor_user_id = 2;
int64 target_user_id = 3;
}
message RecordProfileVisitResponse {
bool recorded = 1;
UserProfileStats target_stats = 2;
}
message ProfileVisitRecord {
int64 visitor_user_id = 1;
int64 target_user_id = 2;
int64 visit_count = 3;
int64 last_visited_at_ms = 4;
}
message ListProfileVisitorsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListProfileVisitorsResponse {
repeated ProfileVisitRecord records = 1;
int64 total = 2;
}
message FollowUserRequest {
RequestMeta meta = 1;
int64 follower_user_id = 2;
int64 followee_user_id = 3;
}
message FollowUserResponse {
bool following = 1;
UserProfileStats follower_stats = 2;
}
message UnfollowUserRequest {
RequestMeta meta = 1;
int64 follower_user_id = 2;
int64 followee_user_id = 3;
}
message UnfollowUserResponse {
bool following = 1;
UserProfileStats follower_stats = 2;
}
message FollowRecord {
int64 follower_user_id = 1;
int64 followee_user_id = 2;
int64 followed_at_ms = 3;
}
message ListFollowingRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListFollowingResponse {
repeated FollowRecord records = 1;
int64 total = 2;
}
message ApplyFriendRequest {
RequestMeta meta = 1;
int64 requester_user_id = 2;
int64 target_user_id = 3;
}
message ApplyFriendResponse {
FriendApplication application = 1;
bool already_friends = 2;
}
message AcceptFriendApplicationRequest {
RequestMeta meta = 1;
int64 accepter_user_id = 2;
int64 requester_user_id = 3;
}
message AcceptFriendApplicationResponse {
FriendRecord friend = 1;
}
message DeleteFriendRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int64 friend_user_id = 3;
}
message DeleteFriendResponse {
bool deleted = 1;
}
message FriendRecord {
int64 user_id = 1;
int64 friend_user_id = 2;
int64 friended_at_ms = 3;
}
message ListFriendsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int32 page = 3;
int32 page_size = 4;
}
message ListFriendsResponse {
repeated FriendRecord records = 1;
int64 total = 2;
}
message FriendApplication {
int64 requester_user_id = 1;
int64 target_user_id = 2;
string status = 3;
int64 created_at_ms = 4;
int64 updated_at_ms = 5;
}
message ListFriendApplicationsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string direction = 3;
int32 page = 4;
int32 page_size = 5;
}
message ListFriendApplicationsResponse {
repeated FriendApplication applications = 1;
int64 total = 2;
}
// BatchGetUsersRequest
message BatchGetUsersRequest {
RequestMeta meta = 1;
@ -461,6 +621,7 @@ message ExpirePrettyDisplayUserIDResponse {
// UserService
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
@ -469,6 +630,20 @@ service UserService {
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
}
// UserSocialService 访
service UserSocialService {
rpc RecordProfileVisit(RecordProfileVisitRequest) returns (RecordProfileVisitResponse);
rpc ListProfileVisitors(ListProfileVisitorsRequest) returns (ListProfileVisitorsResponse);
rpc FollowUser(FollowUserRequest) returns (FollowUserResponse);
rpc UnfollowUser(UnfollowUserRequest) returns (UnfollowUserResponse);
rpc ListFollowing(ListFollowingRequest) returns (ListFollowingResponse);
rpc ApplyFriend(ApplyFriendRequest) returns (ApplyFriendResponse);
rpc AcceptFriendApplication(AcceptFriendApplicationRequest) returns (AcceptFriendApplicationResponse);
rpc DeleteFriend(DeleteFriendRequest) returns (DeleteFriendResponse);
rpc ListFriends(ListFriendsRequest) returns (ListFriendsResponse);
rpc ListFriendApplications(ListFriendApplicationsRequest) returns (ListFriendApplicationsResponse);
}
// UserCronService cron-service user-service owner
service UserCronService {
rpc ProcessLoginIPRiskBatch(CronBatchRequest) returns (CronBatchResponse);

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.29.2
// - protoc v5.27.3
// source: proto/user/v1/user.proto
package userv1
@ -20,6 +20,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
@ -35,6 +36,7 @@ const (
// UserService 提供用户主状态、基础资料和注册资料读写接口。
type UserServiceClient interface {
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error)
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
@ -61,6 +63,16 @@ func (c *userServiceClient) GetUser(ctx context.Context, in *GetUserRequest, opt
return out, nil
}
func (c *userServiceClient) GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyProfileStatsResponse)
err := c.cc.Invoke(ctx, UserService_GetMyProfileStats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetUsersResponse)
@ -128,6 +140,7 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
// UserService 提供用户主状态、基础资料和注册资料读写接口。
type UserServiceServer interface {
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
@ -147,6 +160,9 @@ type UnimplementedUserServiceServer struct{}
func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
}
func (UnimplementedUserServiceServer) GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyProfileStats not implemented")
}
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
}
@ -204,6 +220,24 @@ func _UserService_GetUser_Handler(srv interface{}, ctx context.Context, dec func
return interceptor(ctx, in, info, handler)
}
func _UserService_GetMyProfileStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyProfileStatsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).GetMyProfileStats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_GetMyProfileStats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).GetMyProfileStats(ctx, req.(*GetMyProfileStatsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetUsersRequest)
if err := dec(in); err != nil {
@ -323,6 +357,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetUser",
Handler: _UserService_GetUser_Handler,
},
{
MethodName: "GetMyProfileStats",
Handler: _UserService_GetMyProfileStats_Handler,
},
{
MethodName: "BatchGetUsers",
Handler: _UserService_BatchGetUsers_Handler,
@ -352,6 +390,454 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/user/v1/user.proto",
}
const (
UserSocialService_RecordProfileVisit_FullMethodName = "/hyapp.user.v1.UserSocialService/RecordProfileVisit"
UserSocialService_ListProfileVisitors_FullMethodName = "/hyapp.user.v1.UserSocialService/ListProfileVisitors"
UserSocialService_FollowUser_FullMethodName = "/hyapp.user.v1.UserSocialService/FollowUser"
UserSocialService_UnfollowUser_FullMethodName = "/hyapp.user.v1.UserSocialService/UnfollowUser"
UserSocialService_ListFollowing_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFollowing"
UserSocialService_ApplyFriend_FullMethodName = "/hyapp.user.v1.UserSocialService/ApplyFriend"
UserSocialService_AcceptFriendApplication_FullMethodName = "/hyapp.user.v1.UserSocialService/AcceptFriendApplication"
UserSocialService_DeleteFriend_FullMethodName = "/hyapp.user.v1.UserSocialService/DeleteFriend"
UserSocialService_ListFriends_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriends"
UserSocialService_ListFriendApplications_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriendApplications"
)
// UserSocialServiceClient is the client API for UserSocialService 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.
//
// UserSocialService 承载用户访问、关注和好友关系。
type UserSocialServiceClient interface {
RecordProfileVisit(ctx context.Context, in *RecordProfileVisitRequest, opts ...grpc.CallOption) (*RecordProfileVisitResponse, error)
ListProfileVisitors(ctx context.Context, in *ListProfileVisitorsRequest, opts ...grpc.CallOption) (*ListProfileVisitorsResponse, error)
FollowUser(ctx context.Context, in *FollowUserRequest, opts ...grpc.CallOption) (*FollowUserResponse, error)
UnfollowUser(ctx context.Context, in *UnfollowUserRequest, opts ...grpc.CallOption) (*UnfollowUserResponse, error)
ListFollowing(ctx context.Context, in *ListFollowingRequest, opts ...grpc.CallOption) (*ListFollowingResponse, error)
ApplyFriend(ctx context.Context, in *ApplyFriendRequest, opts ...grpc.CallOption) (*ApplyFriendResponse, error)
AcceptFriendApplication(ctx context.Context, in *AcceptFriendApplicationRequest, opts ...grpc.CallOption) (*AcceptFriendApplicationResponse, error)
DeleteFriend(ctx context.Context, in *DeleteFriendRequest, opts ...grpc.CallOption) (*DeleteFriendResponse, error)
ListFriends(ctx context.Context, in *ListFriendsRequest, opts ...grpc.CallOption) (*ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, in *ListFriendApplicationsRequest, opts ...grpc.CallOption) (*ListFriendApplicationsResponse, error)
}
type userSocialServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserSocialServiceClient(cc grpc.ClientConnInterface) UserSocialServiceClient {
return &userSocialServiceClient{cc}
}
func (c *userSocialServiceClient) RecordProfileVisit(ctx context.Context, in *RecordProfileVisitRequest, opts ...grpc.CallOption) (*RecordProfileVisitResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RecordProfileVisitResponse)
err := c.cc.Invoke(ctx, UserSocialService_RecordProfileVisit_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) ListProfileVisitors(ctx context.Context, in *ListProfileVisitorsRequest, opts ...grpc.CallOption) (*ListProfileVisitorsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListProfileVisitorsResponse)
err := c.cc.Invoke(ctx, UserSocialService_ListProfileVisitors_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) FollowUser(ctx context.Context, in *FollowUserRequest, opts ...grpc.CallOption) (*FollowUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(FollowUserResponse)
err := c.cc.Invoke(ctx, UserSocialService_FollowUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) UnfollowUser(ctx context.Context, in *UnfollowUserRequest, opts ...grpc.CallOption) (*UnfollowUserResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UnfollowUserResponse)
err := c.cc.Invoke(ctx, UserSocialService_UnfollowUser_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) ListFollowing(ctx context.Context, in *ListFollowingRequest, opts ...grpc.CallOption) (*ListFollowingResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListFollowingResponse)
err := c.cc.Invoke(ctx, UserSocialService_ListFollowing_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) ApplyFriend(ctx context.Context, in *ApplyFriendRequest, opts ...grpc.CallOption) (*ApplyFriendResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyFriendResponse)
err := c.cc.Invoke(ctx, UserSocialService_ApplyFriend_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) AcceptFriendApplication(ctx context.Context, in *AcceptFriendApplicationRequest, opts ...grpc.CallOption) (*AcceptFriendApplicationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AcceptFriendApplicationResponse)
err := c.cc.Invoke(ctx, UserSocialService_AcceptFriendApplication_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) DeleteFriend(ctx context.Context, in *DeleteFriendRequest, opts ...grpc.CallOption) (*DeleteFriendResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteFriendResponse)
err := c.cc.Invoke(ctx, UserSocialService_DeleteFriend_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) ListFriends(ctx context.Context, in *ListFriendsRequest, opts ...grpc.CallOption) (*ListFriendsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListFriendsResponse)
err := c.cc.Invoke(ctx, UserSocialService_ListFriends_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userSocialServiceClient) ListFriendApplications(ctx context.Context, in *ListFriendApplicationsRequest, opts ...grpc.CallOption) (*ListFriendApplicationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListFriendApplicationsResponse)
err := c.cc.Invoke(ctx, UserSocialService_ListFriendApplications_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserSocialServiceServer is the server API for UserSocialService service.
// All implementations must embed UnimplementedUserSocialServiceServer
// for forward compatibility.
//
// UserSocialService 承载用户访问、关注和好友关系。
type UserSocialServiceServer interface {
RecordProfileVisit(context.Context, *RecordProfileVisitRequest) (*RecordProfileVisitResponse, error)
ListProfileVisitors(context.Context, *ListProfileVisitorsRequest) (*ListProfileVisitorsResponse, error)
FollowUser(context.Context, *FollowUserRequest) (*FollowUserResponse, error)
UnfollowUser(context.Context, *UnfollowUserRequest) (*UnfollowUserResponse, error)
ListFollowing(context.Context, *ListFollowingRequest) (*ListFollowingResponse, error)
ApplyFriend(context.Context, *ApplyFriendRequest) (*ApplyFriendResponse, error)
AcceptFriendApplication(context.Context, *AcceptFriendApplicationRequest) (*AcceptFriendApplicationResponse, error)
DeleteFriend(context.Context, *DeleteFriendRequest) (*DeleteFriendResponse, error)
ListFriends(context.Context, *ListFriendsRequest) (*ListFriendsResponse, error)
ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error)
mustEmbedUnimplementedUserSocialServiceServer()
}
// UnimplementedUserSocialServiceServer 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 UnimplementedUserSocialServiceServer struct{}
func (UnimplementedUserSocialServiceServer) RecordProfileVisit(context.Context, *RecordProfileVisitRequest) (*RecordProfileVisitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecordProfileVisit not implemented")
}
func (UnimplementedUserSocialServiceServer) ListProfileVisitors(context.Context, *ListProfileVisitorsRequest) (*ListProfileVisitorsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListProfileVisitors not implemented")
}
func (UnimplementedUserSocialServiceServer) FollowUser(context.Context, *FollowUserRequest) (*FollowUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method FollowUser not implemented")
}
func (UnimplementedUserSocialServiceServer) UnfollowUser(context.Context, *UnfollowUserRequest) (*UnfollowUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnfollowUser not implemented")
}
func (UnimplementedUserSocialServiceServer) ListFollowing(context.Context, *ListFollowingRequest) (*ListFollowingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListFollowing not implemented")
}
func (UnimplementedUserSocialServiceServer) ApplyFriend(context.Context, *ApplyFriendRequest) (*ApplyFriendResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyFriend not implemented")
}
func (UnimplementedUserSocialServiceServer) AcceptFriendApplication(context.Context, *AcceptFriendApplicationRequest) (*AcceptFriendApplicationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AcceptFriendApplication not implemented")
}
func (UnimplementedUserSocialServiceServer) DeleteFriend(context.Context, *DeleteFriendRequest) (*DeleteFriendResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteFriend not implemented")
}
func (UnimplementedUserSocialServiceServer) ListFriends(context.Context, *ListFriendsRequest) (*ListFriendsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListFriends not implemented")
}
func (UnimplementedUserSocialServiceServer) ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListFriendApplications not implemented")
}
func (UnimplementedUserSocialServiceServer) mustEmbedUnimplementedUserSocialServiceServer() {}
func (UnimplementedUserSocialServiceServer) testEmbeddedByValue() {}
// UnsafeUserSocialServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to UserSocialServiceServer will
// result in compilation errors.
type UnsafeUserSocialServiceServer interface {
mustEmbedUnimplementedUserSocialServiceServer()
}
func RegisterUserSocialServiceServer(s grpc.ServiceRegistrar, srv UserSocialServiceServer) {
// If the following call pancis, it indicates UnimplementedUserSocialServiceServer 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(&UserSocialService_ServiceDesc, srv)
}
func _UserSocialService_RecordProfileVisit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecordProfileVisitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).RecordProfileVisit(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_RecordProfileVisit_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).RecordProfileVisit(ctx, req.(*RecordProfileVisitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_ListProfileVisitors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListProfileVisitorsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).ListProfileVisitors(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_ListProfileVisitors_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).ListProfileVisitors(ctx, req.(*ListProfileVisitorsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_FollowUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(FollowUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).FollowUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_FollowUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).FollowUser(ctx, req.(*FollowUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_UnfollowUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UnfollowUserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).UnfollowUser(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_UnfollowUser_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).UnfollowUser(ctx, req.(*UnfollowUserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_ListFollowing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListFollowingRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).ListFollowing(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_ListFollowing_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).ListFollowing(ctx, req.(*ListFollowingRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_ApplyFriend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyFriendRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).ApplyFriend(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_ApplyFriend_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).ApplyFriend(ctx, req.(*ApplyFriendRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_AcceptFriendApplication_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AcceptFriendApplicationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).AcceptFriendApplication(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_AcceptFriendApplication_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).AcceptFriendApplication(ctx, req.(*AcceptFriendApplicationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_DeleteFriend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteFriendRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).DeleteFriend(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_DeleteFriend_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).DeleteFriend(ctx, req.(*DeleteFriendRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_ListFriends_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListFriendsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).ListFriends(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_ListFriends_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).ListFriends(ctx, req.(*ListFriendsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_ListFriendApplications_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListFriendApplicationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).ListFriendApplications(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_ListFriendApplications_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).ListFriendApplications(ctx, req.(*ListFriendApplicationsRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserSocialService_ServiceDesc is the grpc.ServiceDesc for UserSocialService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var UserSocialService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.user.v1.UserSocialService",
HandlerType: (*UserSocialServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "RecordProfileVisit",
Handler: _UserSocialService_RecordProfileVisit_Handler,
},
{
MethodName: "ListProfileVisitors",
Handler: _UserSocialService_ListProfileVisitors_Handler,
},
{
MethodName: "FollowUser",
Handler: _UserSocialService_FollowUser_Handler,
},
{
MethodName: "UnfollowUser",
Handler: _UserSocialService_UnfollowUser_Handler,
},
{
MethodName: "ListFollowing",
Handler: _UserSocialService_ListFollowing_Handler,
},
{
MethodName: "ApplyFriend",
Handler: _UserSocialService_ApplyFriend_Handler,
},
{
MethodName: "AcceptFriendApplication",
Handler: _UserSocialService_AcceptFriendApplication_Handler,
},
{
MethodName: "DeleteFriend",
Handler: _UserSocialService_DeleteFriend_Handler,
},
{
MethodName: "ListFriends",
Handler: _UserSocialService_ListFriends_Handler,
},
{
MethodName: "ListFriendApplications",
Handler: _UserSocialService_ListFriendApplications_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}
const (
UserCronService_ProcessLoginIPRiskBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessLoginIPRiskBatch"
UserCronService_ProcessRegionRebuildBatch_FullMethodName = "/hyapp.user.v1.UserCronService/ProcessRegionRebuildBatch"

File diff suppressed because it is too large Load Diff

View File

@ -580,6 +580,207 @@ message ListRechargeBillsResponse {
int64 total = 2;
}
// WalletFeatureFlags 使 wallet-service
message WalletFeatureFlags {
bool recharge_enabled = 1;
bool diamond_exchange_enabled = 2;
bool withdraw_enabled = 3;
}
message GetWalletOverviewRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message GetWalletOverviewResponse {
repeated AssetBalance balances = 1;
WalletFeatureFlags feature_flags = 2;
}
// WalletValueSummary
message WalletValueSummary {
int64 coin_amount = 1;
int64 updated_at_ms = 2;
}
message GetWalletValueSummaryRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message GetWalletValueSummaryResponse {
WalletValueSummary summary = 1;
}
// RechargeProduct App
message RechargeProduct {
int64 product_id = 1;
string product_code = 2;
string channel = 3;
string currency_code = 4;
int64 coin_amount = 5;
int64 amount_minor = 6;
string policy_version = 7;
bool enabled = 8;
int32 sort_order = 9;
}
message ListRechargeProductsRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
int64 region_id = 4;
}
message ListRechargeProductsResponse {
repeated RechargeProduct products = 1;
repeated string channels = 2;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
string to_asset_type = 3;
int64 from_amount = 4;
int64 to_amount = 5;
bool enabled = 6;
}
message GetDiamondExchangeConfigRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message GetDiamondExchangeConfigResponse {
repeated DiamondExchangeRule rules = 1;
}
// WalletTransaction 使
message WalletTransaction {
int64 entry_id = 1;
string transaction_id = 2;
string biz_type = 3;
string asset_type = 4;
int64 available_delta = 5;
int64 frozen_delta = 6;
int64 available_after = 7;
int64 frozen_after = 8;
int64 counterparty_user_id = 9;
string room_id = 10;
int64 created_at_ms = 11;
}
message ListWalletTransactionsRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
string asset_type = 4;
int32 page = 5;
int32 page_size = 6;
}
message ListWalletTransactionsResponse {
repeated WalletTransaction transactions = 1;
int64 total = 2;
}
message WithdrawalRequest {
string withdrawal_id = 1;
int64 user_id = 2;
string asset_type = 3;
int64 amount = 4;
string status = 5;
string payout_account = 6;
string reason = 7;
int64 created_at_ms = 8;
int64 updated_at_ms = 9;
}
message ApplyWithdrawalRequest {
string command_id = 1;
string app_code = 2;
int64 user_id = 3;
int64 amount = 4;
string payout_account = 5;
string reason = 6;
}
message ApplyWithdrawalResponse {
WithdrawalRequest withdrawal = 1;
AssetBalance balance = 2;
}
message VipRewardItem {
int64 resource_id = 1;
string resource_code = 2;
string resource_type = 3;
string name = 4;
int64 quantity = 5;
int64 expires_at_ms = 6;
}
message VipLevel {
int32 level = 1;
string name = 2;
string status = 3;
int64 price_coin = 4;
int64 duration_ms = 5;
int64 reward_resource_group_id = 6;
repeated VipRewardItem reward_items = 7;
bool can_purchase = 8;
int32 sort_order = 9;
}
message UserVip {
int64 user_id = 1;
int32 level = 2;
string name = 3;
bool active = 4;
int64 started_at_ms = 5;
int64 expires_at_ms = 6;
int64 updated_at_ms = 7;
}
message ListVipPackagesRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message ListVipPackagesResponse {
UserVip current_vip = 1;
repeated VipLevel packages = 2;
}
message GetMyVipRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
}
message GetMyVipResponse {
UserVip vip = 1;
}
message PurchaseVipRequest {
string command_id = 1;
string app_code = 2;
int64 user_id = 3;
int32 level = 4;
}
message PurchaseVipResponse {
string order_id = 1;
string transaction_id = 2;
UserVip vip = 3;
int64 coin_spent = 4;
int64 coin_balance_after = 5;
repeated VipRewardItem reward_items = 6;
}
// CreditTaskRewardRequest activity-service
message CreditTaskRewardRequest {
string command_id = 1;
@ -627,5 +828,14 @@ service WalletService {
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc ListResourceGrants(ListResourceGrantsRequest) returns (ListResourceGrantsResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ApplyWithdrawal(ApplyWithdrawalRequest) returns (ApplyWithdrawalResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
rpc CreditTaskReward(CreditTaskRewardRequest) returns (CreditTaskRewardResponse);
}

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.29.2
// - protoc v5.27.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -44,6 +44,15 @@ const (
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ApplyWithdrawal_FullMethodName = "/hyapp.wallet.v1.WalletService/ApplyWithdrawal"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
WalletService_CreditTaskReward_FullMethodName = "/hyapp.wallet.v1.WalletService/CreditTaskReward"
)
@ -78,6 +87,15 @@ type WalletServiceClient interface {
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, in *ListResourceGrantsRequest, opts ...grpc.CallOption) (*ListResourceGrantsResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, in *ApplyWithdrawalRequest, opts ...grpc.CallOption) (*ApplyWithdrawalResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
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)
}
@ -339,6 +357,96 @@ func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRec
return out, nil
}
func (c *walletServiceClient) GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWalletOverviewResponse)
err := c.cc.Invoke(ctx, WalletService_GetWalletOverview_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWalletValueSummaryResponse)
err := c.cc.Invoke(ctx, WalletService_GetWalletValueSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRechargeProductsResponse)
err := c.cc.Invoke(ctx, WalletService_ListRechargeProducts_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
err := c.cc.Invoke(ctx, WalletService_GetDiamondExchangeConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWalletTransactionsResponse)
err := c.cc.Invoke(ctx, WalletService_ListWalletTransactions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ApplyWithdrawal(ctx context.Context, in *ApplyWithdrawalRequest, opts ...grpc.CallOption) (*ApplyWithdrawalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyWithdrawalResponse)
err := c.cc.Invoke(ctx, WalletService_ApplyWithdrawal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListVipPackagesResponse)
err := c.cc.Invoke(ctx, WalletService_ListVipPackages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetMyVipResponse)
err := c.cc.Invoke(ctx, WalletService_GetMyVip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PurchaseVipResponse)
err := c.cc.Invoke(ctx, WalletService_PurchaseVip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) CreditTaskReward(ctx context.Context, in *CreditTaskRewardRequest, opts ...grpc.CallOption) (*CreditTaskRewardResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreditTaskRewardResponse)
@ -380,6 +488,15 @@ type WalletServiceServer interface {
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ApplyWithdrawal(context.Context, *ApplyWithdrawalRequest) (*ApplyWithdrawalResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error)
mustEmbedUnimplementedWalletServiceServer()
}
@ -466,6 +583,33 @@ func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *Lis
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeBills not implemented")
}
func (UnimplementedWalletServiceServer) GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWalletOverview not implemented")
}
func (UnimplementedWalletServiceServer) GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWalletValueSummary not implemented")
}
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeProducts not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
func (UnimplementedWalletServiceServer) ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListWalletTransactions not implemented")
}
func (UnimplementedWalletServiceServer) ApplyWithdrawal(context.Context, *ApplyWithdrawalRequest) (*ApplyWithdrawalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyWithdrawal not implemented")
}
func (UnimplementedWalletServiceServer) ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListVipPackages not implemented")
}
func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMyVip not implemented")
}
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PurchaseVip not implemented")
}
func (UnimplementedWalletServiceServer) CreditTaskReward(context.Context, *CreditTaskRewardRequest) (*CreditTaskRewardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreditTaskReward not implemented")
}
@ -940,6 +1084,168 @@ func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetWalletOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWalletOverviewRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetWalletOverview(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetWalletOverview_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetWalletOverview(ctx, req.(*GetWalletOverviewRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetWalletValueSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWalletValueSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetWalletValueSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetWalletValueSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetWalletValueSummary(ctx, req.(*GetWalletValueSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRechargeProductsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListRechargeProducts(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListRechargeProducts_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListRechargeProducts(ctx, req.(*ListRechargeProductsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetDiamondExchangeConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetDiamondExchangeConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetDiamondExchangeConfig(ctx, req.(*GetDiamondExchangeConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListWalletTransactions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWalletTransactionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListWalletTransactions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListWalletTransactions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListWalletTransactions(ctx, req.(*ListWalletTransactionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ApplyWithdrawal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyWithdrawalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ApplyWithdrawal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ApplyWithdrawal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ApplyWithdrawal(ctx, req.(*ApplyWithdrawalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListVipPackages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListVipPackagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ListVipPackages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ListVipPackages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ListVipPackages(ctx, req.(*ListVipPackagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetMyVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetMyVipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetMyVip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetMyVip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetMyVip(ctx, req.(*GetMyVipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PurchaseVipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).PurchaseVip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_PurchaseVip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).PurchaseVip(ctx, req.(*PurchaseVipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_CreditTaskReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreditTaskRewardRequest)
if err := dec(in); err != nil {
@ -1065,6 +1371,42 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler,
},
{
MethodName: "GetWalletOverview",
Handler: _WalletService_GetWalletOverview_Handler,
},
{
MethodName: "GetWalletValueSummary",
Handler: _WalletService_GetWalletValueSummary_Handler,
},
{
MethodName: "ListRechargeProducts",
Handler: _WalletService_ListRechargeProducts_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,
},
{
MethodName: "ListWalletTransactions",
Handler: _WalletService_ListWalletTransactions_Handler,
},
{
MethodName: "ApplyWithdrawal",
Handler: _WalletService_ApplyWithdrawal_Handler,
},
{
MethodName: "ListVipPackages",
Handler: _WalletService_ListVipPackages_Handler,
},
{
MethodName: "GetMyVip",
Handler: _WalletService_GetMyVip_Handler,
},
{
MethodName: "PurchaseVip",
Handler: _WalletService_PurchaseVip_Handler,
},
{
MethodName: "CreditTaskReward",
Handler: _WalletService_CreditTaskReward_Handler,

View File

@ -1,6 +1,6 @@
# App Entry State Machine
本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),邀请码细节见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。
本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./登录注册技术设计.md),邀请码细节见 [Registration Invite Code Architecture](./注册邀请码架构.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./进房与重连开发需求.md)。
## Goals

View File

@ -5,10 +5,10 @@
相关文档:
```text
docs/voice-room-basic-loop-implementation.md
docs/voice-room-client-api-flow.md
docs/app-message-tab-architecture.md
docs/room-outbox-compensation-development.md
docs/语音房基础闭环实现.md
docs/语音房客户端接口流程.md
docs/App消息Tab架构.md
docs/房间Outbox补偿开发.md
```
## Current State

View File

@ -1,316 +0,0 @@
# My Page Overview API
本文定义 App 底部 `我的` 页首屏聚合接口。该接口只返回首屏渲染需要的轻量摘要:用户资料、我的邀请码、核心资产余额、身份开关、消息红点和入口显隐。流水、成员列表、背包完整列表、工资明细、邀请列表和团队统计必须放到二级页接口。
## Goals
- `我的` 页首屏一次请求拿到稳定摘要,减少 App 冷启动后的接口数量。
- 聚合已有轻量能力:`/users/me``/wallet/me/balances``/users/me/host-identity``/messages/tabs`
- 避免实时 `COUNT(*)`、扫流水、扫成员、扫背包、现算工资。
- 后端控制入口显隐App 不硬编码复杂身份组合。
- 所有数据按 `app_code` 和当前 JWT `user_id` 隔离。
## Endpoint
```http
GET /api/v1/users/me/overview
Authorization: Bearer <access_token>
```
响应仍使用 gateway `/api/v1` envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_01",
"data": {
"profile": {},
"wallet": {},
"invite": {},
"roles": {},
"badges": {},
"entries": [],
"server_time_ms": 1777996800000
}
}
```
## Response Shape
```json
{
"profile": {
"user_id": "10001",
"display_user_id": "163000",
"default_display_user_id": "163000",
"display_user_id_kind": "default",
"display_user_id_expires_at_ms": 0,
"username": "Alice",
"avatar": "https://cdn.example.com/avatar.png",
"gender": "female",
"birth": "2000-01-01",
"country": "US",
"country_id": 1,
"country_name": "United States",
"country_display_name": "United States",
"region_id": 10,
"region_code": "north_america",
"region_name": "North America",
"language": "en",
"profile_completed": true,
"onboarding_status": "completed"
},
"wallet": {
"balances": [
{
"asset_type": "COIN",
"available_amount": 12000,
"frozen_amount": 0,
"version": 8
},
{
"asset_type": "DIAMOND",
"available_amount": 300,
"frozen_amount": 0,
"version": 3
},
{
"asset_type": "USD_BALANCE",
"available_amount": 2500,
"frozen_amount": 500,
"version": 2
}
],
"recharge_enabled": true,
"diamond_exchange_enabled": true,
"withdraw_enabled": true
},
"invite": {
"my_invite_code": "A1B2C3",
"invite_enabled": true,
"invite_count": 12,
"valid_invite_count": 3,
"valid_invite_threshold_coin": 80000
},
"roles": {
"is_host": true,
"is_agent": false,
"is_bd": false,
"is_bd_leader": false,
"is_coin_seller": false
},
"badges": {
"unread_system_messages": 2,
"unread_activity_messages": 1,
"pending_role_invitations": 0
},
"entries": [
{
"key": "wallet",
"title": "Wallet",
"visible": true,
"badge_count": 0
},
{
"key": "host_center",
"title": "Host Center",
"visible": true,
"badge_count": 0
},
{
"key": "coin_seller_center",
"title": "Coin Seller",
"visible": false,
"badge_count": 0
},
{
"key": "backpack",
"title": "Backpack",
"visible": true,
"badge_count": 0
},
{
"key": "settings",
"title": "Settings",
"visible": true,
"badge_count": 0
}
],
"server_time_ms": 1777996800000
}
```
## Field Sources
| Field | Source | Query Pattern |
| --- | --- | --- |
| `profile` | `user-service.GetUser` | single row by `(app_code, user_id)` |
| `wallet.balances` | `wallet-service.GetBalances` | point read for `COIN``DIAMOND``USD_BALANCE` |
| `invite.my_invite_code` | `user-service` invite code module | point read active primary code by `(app_code, user_id)` |
| `invite.invite_count` | invite read model | users that successfully filled this invite code; without read model return `0` or omit |
| `invite.valid_invite_count` | invite read model | invited users whose cumulative eligible recharge reaches the configured threshold |
| `invite.valid_invite_threshold_coin` | invite validity policy | current configured threshold, default `80000` `COIN` |
| `roles` | `user-service` host identity module | point read role facts or future role summary read model |
| `badges.unread_*` | message inbox owner | counter/read model, not message list scan |
| `entries` | gateway composition rules | computed from `roles` and feature flags |
| `server_time_ms` | gateway | current server clock |
当前实现已有 `/users/me``/wallet/me/balances``/users/me/host-identity``/messages/tabs``/users/me/overview` 可以先在 `gateway-service` 聚合这些能力;后续如果角色判断的三次 not-found RPC 变慢,再在 `user-service` 增加 `GetUserRoleSummary` 内部 RPC。
## Asset Rules
首屏只返回三类资产:
| Asset | Show Rule |
| --- | --- |
| `COIN` | 所有用户展示 |
| `DIAMOND` | 所有用户展示,但文案不能暗示可直接消费 |
| `USD_BALANCE` | 有余额、冻结金额、host/agent/bd/coin_seller 身份时展示 |
不要返回 `GIFT_POINT`。积分是后台工资/活动计算输入,不是普通用户钱包可直接操作资产。
## Entry Rules
入口显隐由后端返回App 只负责渲染。
| Entry Key | Visible Rule |
| --- | --- |
| `wallet` | always true |
| `host_center` | `roles.is_host == true` |
| `apply_host` | `roles.is_host == false` |
| `agency_center` | `roles.is_agent == true` |
| `bd_center` | `roles.is_bd == true` |
| `bd_leader_center` | `roles.is_bd_leader == true` |
| `coin_seller_center` | `roles.is_coin_seller == true` |
| `backpack` | always true |
| `settings` | always true |
| `support` | always true |
同一个用户可能同时是 BD 和 host。不要用互斥枚举表达角色必须使用 bool flags。
## Expensive Fields To Exclude
不要在 `overview` 中实时查询这些字段:
| Field | Reason | Replacement |
| --- | --- | --- |
| 邀请用户列表 | 需要分页,可能扫 `user_invite_relations` | 二级页 `GET /api/v1/invites/users` |
| 实时邀请人数 `COUNT(*)` | 高活跃邀请人会让首页变慢 | read model counter 或返回 `0` |
| 邀请奖励流水 | 属于钱包/活动明细 | 二级页分页 |
这些字段不要放进首屏 overview
| Field | Reason | Replacement |
| --- | --- | --- |
| `period_mic_minutes` | 需要统计聚合,可能扫 stats | Host Center 二级页 |
| `period_gift_points` | 需要统计聚合,可能跨日汇总 | Host Center 二级页 |
| `estimated_earning_usd_minor` | 不能首屏现算工资 | 后台工资 read model 或收益页 |
| `agency_member_count` | 容易 `COUNT(*)` members | Agency Center 二级页或 summary table |
| `bd_host_count` | 团队规模统计昂贵 | BD Center 二级页或 summary table |
| `pending_withdrawals` | 扫提现/流水表 | wallet badge counter |
| `recharge_count` | 扫充值记录 | wallet 二级页 |
| `resource_count` | 扫背包权益表 | backpack 二级页 |
| `latest_transactions` | 扫交易流水 | wallet 二级页 |
| `inbox_items` | 扫消息列表 | messages 二级页 |
首屏接口禁止为了红点实时扫大表。需要红点就维护 counter/read model没有 counter 时先不展示该红点。
## Performance Budget
目标:
- P95 backend latency: `< 120ms` in normal region deployment.
- Gateway fanout RPC count: max 4.
- No list scan.
- No transaction history query.
- No member `COUNT(*)`.
- No resource entitlement list.
- No salary calculation.
推荐并行 fanout
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant U as user-service
participant W as wallet-service
participant M as message inbox owner
C->>G: GET /api/v1/users/me/overview
par profile and roles
G->>U: GetUser(user_id)
G->>U: GetUserRoleSummary(user_id)
and wallet
G->>W: GetBalances(COIN, DIAMOND, USD_BALANCE)
and badges
G->>M: ListMessageTabs(user_id)
end
G->>G: compose entries and feature flags
G-->>C: overview
```
MVP 可以复用已有 `GetHostProfile``GetBDProfile``GetCoinSellerProfile` 聚合角色;优化版应增加单个 `GetUserRoleSummary`,减少 not-found RPC 和错误映射成本。
## Degraded Responses
首屏接口可以部分降级,但必须清楚标记。
| Upstream Failure | Response Rule |
| --- | --- |
| `user-service.GetUser` fail | 整体失败,资料是首屏主数据 |
| `wallet-service.GetBalances` fail | 返回空 balances并加 `wallet.unavailable=true` |
| role summary fail | 返回 all false并加 `roles.unavailable=true`,不要误展示特权入口 |
| message tabs fail | unread 置 0并加 `badges.unavailable=true` |
如果某个上游超时gateway 不应无限等待。overview 是首屏接口,应该有比普通操作接口更短的聚合超时预算。
## HTTP Handler Shape
新增路由:
```go
mux.Handle(apiV1Prefix+"/users/me/overview", h.profileAPIHandler(jwtVerifier, h.getMyOverview))
```
handler 只做协议聚合:
1. 从 JWT context 取 `user_id``app_code`
2. 并行请求 profile、balances、roles、message tabs。
3. 把资产补齐为固定三类,缺失资产返回 0。
4. 根据 roles 和 feature flags 生成 entries。
5. 返回 envelope data。
不要在 gateway 写 SQL也不要在 gateway 做工资、成员、资源统计。
## Verification Matrix
| Scenario | Expected |
| --- | --- |
| 普通用户打开我的页 | 返回 profile、COIN/DIAMOND/USD_BALANCE、普通入口 |
| host 打开我的页 | `roles.is_host=true`,展示 `host_center` |
| BD Leader 打开我的页 | `is_bd=true``is_bd_leader=true`,展示 leader 入口 |
| 币商打开我的页 | `is_coin_seller=true`,展示 coin seller 入口 |
| 钱包没有账户行 | 三类资产都返回 0 投影 |
| 消息服务失败 | 我的页仍可展示,消息红点降级为 0 |
| 用户资料服务失败 | overview 整体失败 |
| 大量成员/流水存在 | overview 延迟不受影响,因为不查成员和流水 |
## Implementation Order
1. 新增本文档,确认字段边界和慢查询排除规则。
2. 在 `gateway-service` 增加 `GET /api/v1/users/me/overview` handler。
3. 复用现有 `GetUser``GetBalances``GetHostProfile/GetBDProfile/GetCoinSellerProfile``ListMessageTabs`
4. 增加 handler 单元测试覆盖普通用户、host、BD Leader、币商、钱包缺失、消息降级。
5. 如果角色 RPC 成本偏高,再在 `user-service` 增加 `GetUserRoleSummary`
6. 如果红点需要更多类型,先补 counter/read model再把 badge 加进 overview。
## Critical Rules
- `我的` 页 overview 是轻聚合接口,不是用户全量详情接口。
- 首屏不做 `COUNT(*)`、不扫流水、不扫成员、不查完整背包、不现算工资。
- 钱包余额以 `wallet-service` 为准。
- 用户资料以 `user-service` 为准。
- 角色入口以服务端返回的 `roles``entries` 为准。
- 消息红点以 message inbox counter/read model 为准,用户私聊未读仍以腾讯云 IM SDK 为准。

View File

@ -714,7 +714,7 @@ paths:
- users
summary: 我的页首屏聚合摘要
operationId: getMyOverview
description: 聚合当前用户资料、固定三类钱包余额、Host/Agency/BD/币商身份、消息红点和入口显隐。该接口不查流水、不扫成员、不计算工资;钱包、角色和消息上游失败时按字段 `unavailable=true` 局部降级。
description: 聚合当前用户资料、顶部计数、金币钱包卡片、VIP、Host/Agency/BD/币商身份和入口显隐。该接口不查消息红点、邀请码、钻石、美元余额、流水、成员或工资钱包、VIP、计数和角色上游失败时按字段 `unavailable=true` 局部降级。
security:
- BearerAuth: []
parameters:
@ -911,7 +911,6 @@ paths:
enum:
- hot
- new
- me
- name: cursor
in: query
required: false
@ -943,6 +942,69 @@ paths:
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/feeds:
get:
tags:
- rooms
summary: Mine 房间关系流
operationId: listRoomFeeds
description: 查询 Mine 页下方 Visited/Friend/Following 房间流;该接口读取当前用户的房间 feed 索引,再返回房间卡片和 `im_group_id`,不作为公共房间发现列表。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: tab
in: query
required: true
type: string
enum:
- visited
- friend
- following
- name: cursor
in: query
required: false
type: string
- name: limit
in: query
required: false
type: integer
format: int32
responses:
"200":
description: 查询成功;`visited` 由 JoinRoom 成功写入,`friend/following` 依赖关系流读模型填充。
schema:
$ref: "#/definitions/RoomListEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/me:
get:
tags:
- rooms
summary: 查询我的房间卡片
operationId: getMyRoom
description: Mine 顶部“我的房间”权威查询gateway 使用 access token user_idroom-service 直接读取 rooms 元数据并用 Room Cell 快照补齐卡片字段,不依赖发现页 `room_list_entries` 投影命中。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.has_room=false` 表示当前用户尚未创建房间;有房间时 `data.room.im_group_id` 显式返回腾讯 IM 房间群 ID。
schema:
$ref: "#/definitions/MyRoomEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/current:
get:
tags:
@ -2421,14 +2483,15 @@ definitions:
type: object
required:
- is_host
- is_agent
- is_agency
- is_bd
- is_bd_leader
- is_coin_seller
- pending_role_invitations
properties:
is_host:
type: boolean
is_agent:
is_agency:
type: boolean
is_bd:
type: boolean
@ -2436,147 +2499,143 @@ definitions:
type: boolean
is_coin_seller:
type: boolean
unavailable:
type: boolean
description: 角色上游不可用时为 true此时所有特权身份按 false 返回。
MyOverviewAssetBalanceData:
type: object
required:
- asset_type
- available_amount
- frozen_amount
- version
properties:
asset_type:
host_status:
type: string
enum:
- COIN
- DIAMOND
- USD_BALANCE
available_amount:
agency_id:
type: integer
format: int64
frozen_amount:
bd_id:
type: integer
format: int64
version:
type: integer
format: int64
MyOverviewWalletData:
type: object
required:
- balances
- recharge_enabled
- diamond_exchange_enabled
- withdraw_enabled
properties:
balances:
type: array
description: 固定只返回 COIN、DIAMOND、USD_BALANCE钱包账户行缺失时投影为 0不返回 GIFT_POINT。
items:
$ref: "#/definitions/MyOverviewAssetBalanceData"
recharge_enabled:
type: boolean
diamond_exchange_enabled:
type: boolean
withdraw_enabled:
type: boolean
unavailable:
type: boolean
description: 钱包上游不可用时为 true此时 balances 为空。
MyOverviewInviteData:
type: object
properties:
my_invite_code:
bd_status:
type: string
coin_seller_status:
type: string
description: 当前用户自己的 active primary 邀请码。
invite_enabled:
type: boolean
description: 当前用户的邀请码是否可用于新用户注册归因。
invite_count:
type: integer
format: int64
description: 邀请人数,表示成功填写当前用户邀请码并绑定关系的人数;没有 read model 时返回 0 或不返回,禁止首页实时 COUNT。
valid_invite_count:
type: integer
format: int64
description: 有效邀请人数,表示被邀请用户累计符合条件充值达到后台配置门槛的人数。
valid_invite_threshold_coin:
type: integer
format: int64
description: 当前有效邀请门槛,默认 80000 COIN可由后台配置。
unavailable:
type: boolean
description: 邀请码上游不可用时为 true客户端可隐藏复制入口。
MyOverviewBadgesData:
type: object
required:
- unread_system_messages
- unread_activity_messages
- pending_role_invitations
properties:
unread_system_messages:
type: integer
format: int64
unread_activity_messages:
type: integer
format: int64
pending_role_invitations:
type: integer
format: int64
description: 当前没有独立 counter 时返回 0不扫邀请列表。
unavailable:
type: boolean
description: 消息上游不可用时为 true未读数按 0 返回。
description: 角色上游不可用时为 true此时所有特权身份按 false 返回。
MyOverviewProfileStatsData:
type: object
required:
- visitors_count
- following_count
- friends_count
- updated_at_ms
properties:
visitors_count:
type: integer
format: int64
following_count:
type: integer
format: int64
friends_count:
type: integer
format: int64
updated_at_ms:
type: integer
format: int64
unavailable:
type: boolean
description: 计数 read model 上游不可用时为 true客户端可按 0 展示。
MyOverviewWalletData:
type: object
required:
- coin_amount
- updated_at_ms
properties:
coin_amount:
type: integer
format: int64
description: 我的页首屏只展示金币余额。
updated_at_ms:
type: integer
format: int64
unavailable:
type: boolean
description: 钱包上游不可用时为 true。
MyOverviewVIPData:
type: object
properties:
level:
type: integer
format: int32
name:
type: string
active:
type: boolean
started_at_ms:
type: integer
format: int64
expires_at_ms:
type: integer
format: int64
unavailable:
type: boolean
description: VIP 上游不可用时为 true。
MyOverviewEntryData:
type: object
required:
- key
- title
- visible
- enabled
- route
- icon_key
- badge_count
- order
properties:
key:
type: string
enum:
- wallet
- host_center
- store
- bag
- level
- task
- apply_host
- host_center
- agency_center
- bd_center
- bd_leader_center
- coin_seller_center
- backpack
- settings
- support
title:
type: string
- language
visible:
type: boolean
enabled:
type: boolean
route:
type: string
icon_key:
type: string
badge_count:
type: integer
format: int64
order:
type: integer
format: int32
MyOverviewData:
type: object
required:
- profile
- profile_stats
- wallet
- invite
- vip
- roles
- badges
- entries
- server_time_ms
properties:
profile:
$ref: "#/definitions/UserProfileData"
profile_stats:
$ref: "#/definitions/MyOverviewProfileStatsData"
wallet:
$ref: "#/definitions/MyOverviewWalletData"
invite:
$ref: "#/definitions/MyOverviewInviteData"
vip:
$ref: "#/definitions/MyOverviewVIPData"
roles:
$ref: "#/definitions/MyOverviewRolesData"
badges:
$ref: "#/definitions/MyOverviewBadgesData"
entries:
type: array
items:
@ -3697,6 +3756,19 @@ definitions:
$ref: "#/definitions/RoomListItemData"
next_cursor:
type: string
MyRoomData:
type: object
required:
- has_room
- server_time_ms
properties:
has_room:
type: boolean
room:
$ref: "#/definitions/RoomListItemData"
server_time_ms:
type: integer
format: int64
RoomInitialData:
type: object
required:
@ -4248,6 +4320,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomListData"
MyRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MyRoomData"
CurrentRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -1,6 +1,6 @@
# Host, Agency And BD Admin Architecture
本文只定义后台/Admin 侧实现政策配置、工资周期、工资单、审核、钱包入账、调整单、后台审计和运营干预。App 侧的主播申请、Agency 成员、BD 邀请和用户可见查询见 [Host Agency BD App Architecture](./host-agency-bd-architecture.md)。
本文只定义后台/Admin 侧实现政策配置、工资周期、工资单、审核、钱包入账、调整单、后台审计和运营干预。App 侧的主播申请、Agency 成员、BD 邀请和用户可见查询见 [Host Agency BD App Architecture](./主播公会BD架构.md)。
这里的工资统一指发放到主播侧 `USD_BALANCE` 的业务奖励。提现审核和人工打款仍由 `wallet-service` 拥有Admin 只负责把审核通过的工资单入账到工资余额,不直接处理线下打款。
@ -284,7 +284,7 @@ stateDiagram-v2
### Host Salary
输入来自 App 文档定义的 `host_daily_stats`。基础麦上在线事实先按所有用户沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)`host_daily_stats` 是工资需要的关系快照派生表。
输入来自 App 文档定义的 `host_daily_stats`。基础麦上在线事实先按所有用户沉淀,详见 [User Mic Online Time Architecture](./用户麦上在线时长架构.md)`host_daily_stats` 是工资需要的关系快照派生表。
- Sum `mic_online_ms` within cycle。
- Sum `gift_point_received` within cycle。
@ -433,7 +433,7 @@ service UserHostAdminService {
```
`CreateBDLeaderRequest` 必须携带 `region_id`。user-service 校验该区域 active锁定目标用户行必要时更新 `users.region_id`,再创建 `bd_profiles`;同一个 `command_id` 重试只返回已创建的 BD Leader 事实,不重复迁移。
这属于允许改变用户区域的后台关系管理路径之一;完整来源见 [User Region Change Sources](./user-region-country-development.md#user-region-change-sources)。
这属于允许改变用户区域的后台关系管理路径之一;完整来源见 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources)。
当前项目仍在开发阶段proto 可以按当前事实调整;修改 `api/proto` 后必须运行 `make proto` 并提交生成文件。
@ -466,7 +466,7 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
## App Message Notifications
后台关系和工资动作需要通知 App 用户时,统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`hyapp-admin-server` 只负责后台权限和审计App 消息事实由 message inbox owner 落库。
后台关系和工资动作需要通知 App 用户时,统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./App消息Tab架构.md)。`hyapp-admin-server` 只负责后台权限和审计App 消息事实由 message inbox owner 落库。
| Admin/Salary Fact | Target User | Message Type | Action |
| --- | --- | --- | --- |
@ -495,7 +495,7 @@ Outbox consumers can update App message inbox, BI, risk systems, and export jobs
## Implementation Order
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./host-agency-bd-implementation-sequence.md)。Admin 文档只保留后台政策、工资、入账、调整和权限审计设计,避免和 App 实施顺序混在一起。
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./主播公会BD实施顺序.md)。Admin 文档只保留后台政策、工资、入账、调整和权限审计设计,避免和 App 实施顺序混在一起。
## Verification Matrix

View File

@ -1,6 +1,6 @@
# Host, Agency And BD Implementation Sequence
本文定义主播、Agency、BD、BD Leader 功能的开发顺序。详细 App 架构见 [Host Agency BD App Architecture](./host-agency-bd-architecture.md),后台工资和政策架构见 [Host Agency BD Admin Architecture](./host-agency-bd-admin-architecture.md)。
本文定义主播、Agency、BD、BD Leader 功能的开发顺序。详细 App 架构见 [Host Agency BD App Architecture](./主播公会BD架构.md),后台工资和政策架构见 [Host Agency BD Admin Architecture](./主播公会BD后台架构.md)。
核心原则:先做关系事实,再做 App 主链路,最后做统计和工资。工资依赖 host、Agency、BD 关系快照和有效上麦/礼物统计;如果先做工资后台,会得到一套没有可靠输入的数据系统。
@ -94,7 +94,7 @@
交付:
- host outbox 事件接入 message inbox owner具体接口和表见 [App Message Tab Architecture](./app-message-tab-architecture.md)。
- host outbox 事件接入 message inbox owner具体接口和表见 [App Message Tab Architecture](./App消息Tab架构.md)。
- Agency 申请提交后通知 Agency owner 或授权审核者。
- 申请通过、拒绝、被踢出 Agency 时通知目标用户。
- BD/BD Leader 邀请 Agency 或 BD 时通知被邀请人。
@ -111,7 +111,7 @@
## Phase 4: Stats And Attribution
关系主链路稳定后再消费房间和钱包事件。麦上在线时长先按所有用户统一沉淀,详见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。host stats 是派生读模型;工资和团队统计必须使用派生时的关系快照,不能从当前 membership 反推历史归属。
关系主链路稳定后再消费房间和钱包事件。麦上在线时长先按所有用户统一沉淀,详见 [User Mic Online Time Architecture](./用户麦上在线时长架构.md)。host stats 是派生读模型;工资和团队统计必须使用派生时的关系快照,不能从当前 membership 反推历史归属。
交付:

View File

@ -2,7 +2,7 @@
本文只定义 App 侧的主播组织、关系和用户可操作流程。这里的 `host` 指业务主播,不等同于 `room-service` 里的房间 `host_user_id`。房间 `host_user_id` 是单个房间的主持/管理身份;本文的主播身份是用户长期经营身份,可以有 Agency 归属和收益展示。
后台政策、工资周期、工资单审批、钱包入账和调整单实现单独放在 [Host Agency BD Admin Architecture](./host-agency-bd-admin-architecture.md)。App 侧可以读取收益和结算状态,但不能配置政策、触发结算或直接入账。
后台政策、工资周期、工资单审批、钱包入账和调整单实现单独放在 [Host Agency BD Admin Architecture](./主播公会BD后台架构.md)。App 侧可以读取收益和结算状态,但不能配置政策、触发结算或直接入账。
旧 Java 项目只作为术语和历史口径参考。新架构按当前 Go 服务边界重新设计:不新增独立 `host-service` 微服务主播、Agency、BD 层级、申请邀请和 App 查询读模型属于 user-services代码落在当前仓库的 `services/user-service` host domain。`room-service` 只产生房间事件,`wallet-service` 负责余额和提现。
@ -282,7 +282,7 @@ agency_memberships(
用户所属区域来自 `user-service.users.region_id`,不能由客户端提交。
国家/区域后台管理边界在 `hyapp-admin-server`:创建国家、启用/禁用国家、创建区域、启用/禁用区域不属于 App 侧 user-service RPC。App 侧只消费 user-service 已计算好的 `region_id` 和区域投影。
后台创建 BD Leader 是关系管理命令,不是客户端区域提交:`hyapp-admin-server` 负责权限和审计,`user-service` 在同一事务里把目标用户的 `users.region_id` 更新为后台选择的区域,并用同一个区域创建 `bd_profiles`
用户区域允许变化的完整来源以 [User Region Change Sources](./user-region-country-development.md#user-region-change-sources) 为准Host/Agency/BD 关系只消费当前 `users.region_id` 和各关系表里的区域快照。
用户区域允许变化的完整来源以 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources) 为准Host/Agency/BD 关系只消费当前 `users.region_id` 和各关系表里的区域快照。
| Action | Region Rule |
| --- | --- |
@ -390,7 +390,7 @@ Leader inviting Agency uses the same Agency creation flow as BD, but `parent_bd_
App 统计和后台结算都不能直接从当前 membership 反查,因为关系会变化。每条工作指标必须在发生时固化归属快照。
麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 [User Mic Online Time Architecture](./user-mic-online-time-architecture.md)。本节的 `host_daily_stats` 是主播、Agency、BD 工资和团队统计的派生读模型,不是唯一时长来源。
麦上在线时长的基础事实不是主播专属指标,先按所有用户统一沉淀。通用用户麦上时长见 [User Mic Online Time Architecture](./用户麦上在线时长架构.md)。本节的 `host_daily_stats` 是主播、Agency、BD 工资和团队统计的派生读模型,不是唯一时长来源。
### Input Events
@ -585,7 +585,7 @@ Outbox consumers can update search indexes, notifications, BI, admin views, and
## App Message Tab Integration
Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./app-message-tab-architecture.md)。`user-service` host domain 只产出 host outbox 事件或调用内部 `CreateInboxMessage`;不能直接写 message inbox 表,也不能复用后台 `admin_notifications`
Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system` 分区,具体 message inbox 设计见 [App Message Tab Architecture](./App消息Tab架构.md)。`user-service` host domain 只产出 host outbox 事件或调用内部 `CreateInboxMessage`;不能直接写 message inbox 表,也不能复用后台 `admin_notifications`
| Source Event | Target User | Message Type | Action |
| --- | --- | --- | --- |
@ -616,7 +616,7 @@ Host/Agency/BD 的 App 可见通知统一进入 App `消息` tab 的 `system`
## Implementation Order
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./host-agency-bd-implementation-sequence.md)。App 文档只保留 App 侧目标、边界、流程和验收口径,避免和后台工资实施顺序重复。
整体开发顺序单独维护在 [Host Agency BD Implementation Sequence](./主播公会BD实施顺序.md)。App 文档只保留 App 侧目标、边界、流程和验收口径,避免和后台工资实施顺序重复。
## Verification Matrix

View File

@ -24,7 +24,7 @@
健康检查规则见:
```text
docs/health-checks.md
docs/健康检查.md
```
## Shutdown State Machine

View File

@ -1,6 +1,6 @@
# Health Check Development Guide
本文档只定义部署稳定性需要的健康检查能力。健康检查不承载业务判断,不替代监控告警,也不用于修复故障。进程下线和 drain 细节见 `docs/graceful-shutdown.md`。
本文档只定义部署稳定性需要的健康检查能力。健康检查不承载业务判断,不替代监控告警,也不用于修复故障。进程下线和 drain 细节见 `docs/优雅停机.md`。
## Goals

View File

@ -1,6 +1,6 @@
# Lucky Gift Activity Service Development Guide
本文档定义幸运礼物在 `activity-service` 中的开发落地。产品规则见 `docs/lucky-gift-product.md`。
本文档定义幸运礼物在 `activity-service` 中的开发落地。产品规则见 `docs/幸运礼物产品方案.md`。
## Ownership

View File

@ -0,0 +1,185 @@
# My Page Overview API
本文定义 App 底部 `我的` 页首屏聚合接口。首屏只返回设计稿可直接展示的轻量摘要基础资料、顶部计数、金币卡片、VIP 卡片、身份入口和固定功能入口。
## Endpoint
```http
GET /api/v1/users/me/overview
Authorization: Bearer <access_token>
```
响应使用 gateway `/api/v1` envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_01",
"data": {
"profile": {
"user_id": "10001",
"display_user_id": "163000",
"default_display_user_id": "163000",
"display_user_id_kind": "default",
"display_user_id_expires_at_ms": 0,
"username": "Alice",
"avatar": "https://cdn.example.com/avatar.png",
"language": "en",
"profile_completed": true,
"onboarding_status": "completed"
},
"profile_stats": {
"visitors_count": 12,
"following_count": 34,
"friends_count": 56,
"updated_at_ms": 1777996800000
},
"wallet": {
"coin_amount": 12000,
"updated_at_ms": 1777996800000
},
"vip": {
"level": 3,
"name": "VIP3",
"active": true,
"started_at_ms": 1777392000000,
"expires_at_ms": 1777996800000
},
"roles": {
"is_host": true,
"is_agency": false,
"is_bd": false,
"is_bd_leader": false,
"is_coin_seller": false,
"host_status": "active",
"agency_id": 0,
"bd_id": 0,
"pending_role_invitations": 0
},
"entries": [
{
"key": "store",
"visible": true,
"enabled": true,
"route": "/store",
"icon_key": "store",
"badge_count": 0,
"order": 10
},
{
"key": "apply_host",
"visible": false,
"enabled": false,
"route": "/host/apply",
"icon_key": "apply_host",
"badge_count": 0,
"order": 70
},
{
"key": "host_center",
"visible": true,
"enabled": true,
"route": "/host",
"icon_key": "host_center",
"badge_count": 0,
"order": 80
}
],
"server_time_ms": 1777996800000
}
}
```
## Field Sources
| Field | Source | Rule |
| --- | --- | --- |
| `profile` | `user-service.GetUser` | 只取头像、昵称、展示 ID、语言等基础资料 |
| `profile_stats` | `user-service.GetMyProfileStats` | 读 `user_profile_stats` 计数表,不实时扫访问、关注、好友大表 |
| `wallet` | `wallet-service.GetWalletValueSummary` | 首屏只返回金币余额,不返回钻石和美元余额 |
| `vip` | `wallet-service.GetMyVip` | 只取等级、名称、有效期和 active 状态 |
| `roles` | `user-service.GetUserRoleSummary` | 所有身份统一判断,避免 gateway 多 RPC fanout |
| `entries` | gateway composition | 根据 `roles` 生成入口显隐;标题由 App 按 key 做多语言 |
| `server_time_ms` | gateway | 当前服务端毫秒时间 |
`我的` 页首屏不调用 `ListMessageTabs`,不返回系统消息红点、活动消息红点、邀请码、钻石、美元余额,也不返回钱包操作开关。充值、提现、兑换能力在钱包二级页取。
## Entry Rules
| Entry Key | Visible Rule |
| --- | --- |
| `store` | always true |
| `bag` | always true |
| `level` | always true |
| `task` | always true |
| `apply_host` | `roles.is_host == false` |
| `host_center` | `roles.is_host == true` |
| `agency_center` | `roles.is_agency == true` |
| `bd_center` | `roles.is_bd == true` |
| `bd_leader_center` | `roles.is_bd_leader == true` |
| `coin_seller_center` | `roles.is_coin_seller == true` |
| `settings` | always true |
| `language` | always true |
同一个用户可能同时拥有多个身份。角色必须用 bool flags 表达,不要用互斥枚举。
## Excluded From Overview
这些内容不能放进首屏接口:
| Field | Reason | Replacement |
| --- | --- | --- |
| 消息 tab、系统消息、活动消息 | 我的页设计稿不展示消息红点 | 消息 tab 页面接口 |
| 邀请码入口和邀请统计 | 截图首屏没有邀请码入口 | 邀请页或活动入口 |
| `wallet.recharge_enabled` / `withdraw_enabled` / `diamond_exchange_enabled` | 首屏卡片不需要操作开关 | `GET /api/v1/wallet/me/overview` |
| `DIAMOND` / `USD_BALANCE` | 首屏钱包卡片只展示金币 | 钱包二级页余额接口 |
| 邀请用户列表、钱包流水、工资明细、团队成员、背包完整列表、任务列表 | 都需要分页或聚合 | 对应二级页接口 |
## Performance Budget
- 聚合超时预算:`200ms`
- 必须并行请求 profile、profile stats、wallet value summary、VIP、role summary。
- 允许 wallet、VIP、profile stats、role summary 降级并返回 `unavailable=true`
- `user-service.GetUser` 失败时整体失败,因为基础资料是首屏主数据。
- 首屏不得实时 `COUNT(*)`、不得扫流水、不得扫成员、不得查完整背包、不得现算工资。
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant U as user-service
participant W as wallet-service
C->>G: GET /api/v1/users/me/overview
par profile
G->>U: GetUser(user_id)
and stats
G->>U: GetMyProfileStats(user_id)
and roles
G->>U: GetUserRoleSummary(user_id)
and wallet
G->>W: GetWalletValueSummary(user_id)
G->>W: GetMyVip(user_id)
end
G->>G: compose entries
G-->>C: overview
```
## Secondary Endpoints
| Endpoint | Purpose |
| --- | --- |
| `GET /api/v1/users/me/visitors` | 访问记录列表 |
| `GET /api/v1/users/me/following` | 关注列表 |
| `GET /api/v1/users/me/friends` | 好友列表 |
| `GET /api/v1/users/me/friend-requests` | 好友申请列表 |
| `GET /api/v1/users/me/role-summary` | 单独刷新身份摘要 |
| `GET /api/v1/wallet/me/overview` | 钱包二级页余额和操作开关 |
| `GET /api/v1/wallet/recharge/products` | 当前区域充值渠道和档位 |
| `GET /api/v1/wallet/diamond-exchange/config` | 钻石兑换金币/余额配置 |
| `POST /api/v1/wallet/withdrawals/apply` | 创建待审核提现申请并冻结 `USD_BALANCE` |
| `GET /api/v1/wallet/transactions` | 钱包流水分页 |
| `GET /api/v1/vip/me` | 当前 VIP 状态 |
| `GET /api/v1/vip/packages` | 可购买 VIP 包 |
| `POST /api/v1/vip/purchase` | 购买、续期或升级 VIP |

View File

@ -31,12 +31,25 @@
| 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` | 我的页首屏聚合摘要 |
| GET | `/api/v1/users/me/role-summary` | users | `getMyRoleSummary` | 查询当前用户角色摘要 |
| POST | `/api/v1/users/me/onboarding/complete` | users | `completeMyOnboarding` | 完成注册页必填资料 |
| POST | `/api/v1/users/me/profile/update` | users | `updateMyProfile` | 修改当前用户基础资料 |
| POST | `/api/v1/users/me/country/change` | users | `changeMyCountry` | 修改当前用户国家 |
| POST | `/api/v1/users/me/display-id/change` | users | `changeMyDisplayUserID` | 修改当前用户默认短号 |
| POST | `/api/v1/users/me/display-id/pretty/apply` | users | `applyMyPrettyDisplayUserID` | 申请临时靓号 |
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,卡片返回 `im_group_id` |
| POST | `/api/v1/users/{user_id}/visit` | users | `recordProfileVisit` | 记录访问用户主页 |
| GET | `/api/v1/users/me/visitors` | users | `listMyProfileVisitors` | 我的访问记录列表 |
| POST | `/api/v1/users/{user_id}/follow` | users | `followUser` | 关注用户 |
| DELETE | `/api/v1/users/{user_id}/follow` | users | `unfollowUser` | 取消关注用户 |
| GET | `/api/v1/users/me/following` | users | `listMyFollowing` | 我的关注列表 |
| POST | `/api/v1/users/{user_id}/friend-requests` | users | `applyFriend` | 创建好友申请,客户端随后发 IM 申请消息 |
| POST | `/api/v1/users/{user_id}/friend-requests/accept` | users | `acceptFriendApplication` | 同意该用户发来的好友申请 |
| GET | `/api/v1/users/me/friend-requests` | users | `listMyFriendApplications` | 我的好友申请列表 |
| GET | `/api/v1/users/me/friends` | users | `listMyFriends` | 我的好友列表 |
| DELETE | `/api/v1/users/{user_id}/friend` | users | `deleteFriend` | 双向删除好友关系 |
| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表,支持 `tab=hot/new`,卡片返回 `im_group_id` |
| GET | `/api/v1/rooms/me` | rooms | `getMyRoom` | 查询 Mine 顶部我的房间卡片 |
| 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` | 查询房间完整快照 |
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
@ -65,7 +78,16 @@
| POST | `/api/v1/messages/read-all` | messages | `markInboxSectionRead` | 标记分区全部已读 |
| POST | `/api/v1/messages/{message_id}/read` | messages | `markInboxMessageRead` | 标记单条消息已读 |
| DELETE | `/api/v1/messages/{message_id}` | messages | `deleteInboxMessage` | 删除单条消息 |
| GET | `/api/v1/wallet/me/overview` | wallet | `getWalletOverview` | 钱包二级页摘要 |
| GET | `/api/v1/wallet/me/balances` | wallet | `getMyBalances` | 查询当前用户钱包余额 |
| GET | `/api/v1/wallet/recharge/products` | wallet | `listRechargeProducts` | 查询充值商品和渠道 |
| GET | `/api/v1/wallet/diamond-exchange/config` | wallet | `getDiamondExchangeConfig` | 查询钻石兑换配置 |
| POST | `/api/v1/wallet/withdrawals/apply` | wallet | `applyWithdrawal` | 提交美元余额提现申请 |
| GET | `/api/v1/wallet/transactions` | wallet | `listWalletTransactions` | 分页查询钱包流水 |
| POST | `/api/v1/wallet/coin-seller/transfer` | wallet | `transferCoinFromSeller` | 币商给玩家转金币 |
| GET | `/api/v1/vip/me` | vip | `getMyVIP` | 查询当前 VIP 状态 |
| GET | `/api/v1/vip/packages` | vip | `listVIPPackages` | 查询可购买 VIP 套餐 |
| POST | `/api/v1/vip/purchase` | vip | `purchaseVIP` | 购买、续费或升级 VIP |
## Admin HTTP
@ -201,11 +223,22 @@
| POST | `/hyapp.user.v1.AuthService/RefreshToken` | auth | `userAuthRefreshToken` | 刷新 token |
| POST | `/hyapp.user.v1.AuthService/Logout` | auth | `userAuthLogout` | 登出并失效 refresh session |
| POST | `/hyapp.user.v1.UserService/GetUser` | users | `userGetUser` | 查询单个用户主状态 |
| POST | `/hyapp.user.v1.UserService/GetMyProfileStats` | users | `userGetMyProfileStats` | 查询我的页顶部计数 read model |
| POST | `/hyapp.user.v1.UserService/BatchGetUsers` | users | `userBatchGetUsers` | 批量查询用户主状态 |
| POST | `/hyapp.user.v1.UserService/ListUserIDs` | users | `userListUserIDs` | 后台任务按 user_id 游标查询目标用户 |
| POST | `/hyapp.user.v1.UserService/UpdateUserProfile` | users | `userUpdateUserProfile` | 修改用户基础资料 |
| POST | `/hyapp.user.v1.UserService/ChangeUserCountry` | users | `userChangeUserCountry` | 修改用户国家 |
| POST | `/hyapp.user.v1.UserService/CompleteOnboarding` | users | `userCompleteOnboarding` | 完成注册页必填资料 |
| POST | `/hyapp.user.v1.UserSocialService/RecordProfileVisit` | users | `userRecordProfileVisit` | 记录访问用户主页 |
| POST | `/hyapp.user.v1.UserSocialService/ListProfileVisitors` | users | `userListProfileVisitors` | 查询访问记录列表 |
| POST | `/hyapp.user.v1.UserSocialService/FollowUser` | users | `userFollowUser` | 关注用户 |
| POST | `/hyapp.user.v1.UserSocialService/UnfollowUser` | users | `userUnfollowUser` | 取消关注 |
| POST | `/hyapp.user.v1.UserSocialService/ListFollowing` | users | `userListFollowing` | 查询关注列表 |
| POST | `/hyapp.user.v1.UserSocialService/ApplyFriend` | users | `userApplyFriend` | 创建好友申请 |
| POST | `/hyapp.user.v1.UserSocialService/AcceptFriendApplication` | users | `userAcceptFriendApplication` | 同意好友申请并写双向好友关系 |
| POST | `/hyapp.user.v1.UserSocialService/DeleteFriend` | users | `userDeleteFriend` | 删除双向好友关系 |
| POST | `/hyapp.user.v1.UserSocialService/ListFriends` | users | `userListFriends` | 查询好友列表 |
| POST | `/hyapp.user.v1.UserSocialService/ListFriendApplications` | users | `userListFriendApplications` | 查询好友申请列表 |
| POST | `/hyapp.user.v1.UserDeviceService/BindPushToken` | user-devices | `userBindPushToken` | 绑定当前登录用户的系统推送 token |
| POST | `/hyapp.user.v1.UserDeviceService/DeletePushToken` | user-devices | `userDeletePushToken` | 失效当前登录用户的系统推送 token |
| POST | `/hyapp.user.v1.CountryAdminService/CreateCountry` | country-admin | `userCreateCountry` | 创建国家主数据 |

View File

@ -578,7 +578,7 @@ activity-service 额外需要:
推荐顺序:
1. 写 `docs/grpc-error-model.md` 或把本文件错误模型抽成独立文档。
1. 写 `docs/grpc错误模型.md` 或把本文件错误模型抽成独立文档。
2. 扩展 `pkg/xerr`,支持 reason 和 transport 映射。
3. 新增 `api/proto/user/v1/auth.proto``user.proto`
4. 补 `user-service` 的 app/service/domain/storage/transport 骨架。

View File

@ -1,6 +1,6 @@
# Login And Registration Technical Design
本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./registration-invite-code-architecture.md)。
本文档定义 v1 登录、三方注册登录、密码设置、邀请码归因和 token 生命周期。它只描述当前架构事实和接口边界,不记录实现流水账。邀请码详细架构见 [Registration Invite Code Architecture](./注册邀请码架构.md)。
## Scope

113
docs/社交关系接口.md Normal file
View File

@ -0,0 +1,113 @@
# Social Relations API
本文定义 App 端访问记录、关注、好友申请和好友关系接口。关系事实由 `user-service` 持久化;腾讯云 IM 只负责把好友申请消息送进聊天窗口,不作为好友关系事实来源。
## Core Flow
```mermaid
sequenceDiagram
participant A as User A App
participant G as gateway-service
participant U as user-service
participant IM as Tencent IM
participant B as User B App
A->>G: POST /api/v1/users/{B}/friend-requests
G->>U: ApplyFriend(A, B)
U-->>G: pending application
G-->>A: application=pending
A->>IM: send custom friend request message to B
IM-->>B: chat window message
B->>G: POST /api/v1/users/{A}/friend-requests/accept
G->>U: AcceptFriendApplication(B, A)
U-->>G: bidirectional friendship
G-->>B: friend record
```
关键边界:
- 申请接口只创建 pending 申请,不直接发 IM。
- 同意必须调用 accept 接口,不能只靠 IM 消息状态。
- 删除好友是双向删除。
- 自己访问自己、关注自己、加自己好友全部拒绝。
- 重复访问只增加访问次数,不重复增加 `visitors_count`
- 重复关注、重复取消关注、重复同意、重复删除都按幂等处理。
## HTTP Endpoints
所有接口都要求完成资料的登录态,响应统一使用 `{code,message,request_id,data}` envelope。
| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/v1/users/{user_id}/visit` | 记录访问用户主页 |
| `GET` | `/api/v1/users/me/visitors?page=1&page_size=20` | 我的访问记录 |
| `POST` | `/api/v1/users/{user_id}/follow` | 关注用户 |
| `DELETE` | `/api/v1/users/{user_id}/follow` | 取消关注 |
| `GET` | `/api/v1/users/me/following?page=1&page_size=20` | 我的关注列表 |
| `POST` | `/api/v1/users/{user_id}/friend-requests` | 申请添加好友 |
| `POST` | `/api/v1/users/{user_id}/friend-requests/accept` | 同意该用户发来的好友申请 |
| `GET` | `/api/v1/users/me/friend-requests?direction=incoming&page=1&page_size=20` | 好友申请列表,`direction` 支持 `incoming`/`outgoing` |
| `GET` | `/api/v1/users/me/friends?page=1&page_size=20` | 我的好友列表 |
| `DELETE` | `/api/v1/users/{user_id}/friend` | 删除好友 |
## Response Examples
申请好友:
```json
{
"application": {
"requester_user_id": "10001",
"target_user_id": "10002",
"status": "pending",
"created_at_ms": 1777996800000,
"updated_at_ms": 1777996800000
},
"already_friends": false
}
```
同意好友:
```json
{
"friend": {
"user_id": "10002",
"friend_user_id": "10001",
"friended_at_ms": 1777996810000
}
}
```
关注:
```json
{
"following": true,
"follower_stats": {
"visitors_count": 0,
"following_count": 1,
"friends_count": 0,
"updated_at_ms": 1777996800000
}
}
```
## Storage
| Table | Owner | Purpose |
| --- | --- | --- |
| `user_profile_stats` | user-service | 我的页计数 read model访问人数、关注数、好友数 |
| `user_profile_visits` | user-service | 用户主页访问去重记录,主键 `(app_code,target_user_id,visitor_user_id)` |
| `user_follows` | user-service | 单向关注关系,取消关注只改状态 |
| `user_friend_applications` | user-service | 好友申请pending/accepted |
| `user_friendships` | user-service | 双向好友边,每个好友关系写两行 |
计数更新和关系边写入必须在同一个 MySQL 事务中完成。`user_profile_stats` 是首屏计数来源,不能在 `/users/me/overview` 里实时扫关系表。
## App Notes
- 好友申请成功后App 发送腾讯 IM 自定义消息给对方;消息里携带 `requester_user_id` 和申请状态展示参数。
- 聊天窗口点击同意时调用 `/api/v1/users/{requester_user_id}/friend-requests/accept`
- 如果 accept 返回成功App 再刷新好友列表或本地会话状态。
- 如果 IM 消息丢失pending 申请仍可在 `/api/v1/users/me/friend-requests?direction=incoming` 找回。

View File

@ -491,7 +491,7 @@ room_mic_publish_stale_event_ignored_total
## Implementation Order
1. 新增 `docs/tencent-rtc-token-uid-mapping-development.md` 并确认产品边界。
1. 新增 `docs/腾讯RTC票据UID映射开发.md` 并确认产品边界。
2. 抽出通用 Tencent UserSig 算法到 `pkg/tencentsig`,保持 IM usersig 测试通过。
3. 新增 `pkg/tencentrtc`,定义 RTC token config/result 和 `GenerateToken`
4. 扩展 gateway config`tencent_rtc`

View File

@ -33,7 +33,7 @@
| 靓号租约 | `DONE` | pretty display id lease 表和接口存在 | 购买/支付入口未形成完整商业闭环 |
| 用户资料更新 | `DONE` | 支持 username/avatar/birth国家独立接口 | 房间内资料变更实时刷新未做 |
| 国家修改和审计 | `DONE` | `ChangeUserCountry``user_country_change_logs`、冷却期 | 新增“区域”能力尚未实现 |
| 区域功能 | `TODO` | 已有需求文档 `docs/user-region-country-development.md` | regions/region_countries 表、管理端、注册归属、历史回填 |
| 区域功能 | `TODO` | 已有需求文档 `docs/用户区域与国家开发.md` | regions/region_countries 表、管理端、注册归属、历史回填 |
## Gateway HTTP Surface
@ -139,7 +139,7 @@
| --- | --- | --- | --- |
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 尚未消费 room outbox |
| room outbox 消费边界 | `PARTIAL` | `consumer/room` 有接口骨架 | 真实 polling/MQ/offset/idempotency 未实现 |
| 幸运礼物开发文档 | `DONE` | `docs/lucky-gift-activity-service-development.md` | 按文档实现 proto、pool、risk、draw |
| 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw |
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
| LuckyGift Check | `TODO` | 文档有设计proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 |
| LuckyGift Draw | `TODO` | 文档有设计proto/code 未实现 | 三层奖池、一层风控、幂等 draw |

View File

@ -142,7 +142,7 @@ sequenceDiagram
- `visible_region_id` 来自创建者当前 `users.region_id`
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
- 房间创建成功后,创建者改国家不自动修改房间区域。
- 用户 `users.region_id` 允许变化的来源见 [User Region Change Sources](./user-region-country-development.md#user-region-change-sources)。无论是用户改国家、区域映射 rebuild还是后台创建 BD Leader 导致用户区域变化,都不自动迁移已创建房间的 `visible_region_id`
- 用户 `users.region_id` 允许变化的来源见 [User Region Change Sources](./用户区域与国家开发.md#user-region-change-sources)。无论是用户改国家、区域映射 rebuild还是后台创建 BD Leader 导致用户区域变化,都不自动迁移已创建房间的 `visible_region_id`
- 管理端迁移房间区域必须走独立命令,例如 `ChangeRoomVisibleRegion`,并写审计。
### Why Not Dynamic By Owner Region

View File

@ -5,7 +5,7 @@
Room Cell 的完整状态机见:
```text
docs/room-cell-state-machines.md
docs/房间Cell状态机.md
```
## Scope

View File

@ -34,8 +34,13 @@ sequenceDiagram
G-->>C: IM user_sig
C->>IM: login(user_id, user_sig)
C->>G: GET /api/v1/rooms
G->>R: ListRooms(viewer region from user-service)
C->>G: GET /api/v1/rooms/me
G->>R: GetMyRoom(owner_user_id from token)
R-->>G: owner room card from rooms + snapshot
G-->>C: has_room + room card + im_group_id
C->>G: GET /api/v1/rooms/feeds?tab=visited|friend|following
G->>R: ListRoomFeeds(viewer region from user-service)
R-->>G: room cards
G-->>C: room cards + im_group_id
@ -64,11 +69,12 @@ sequenceDiagram
| --- | --- | --- | --- |
| 1 | `GET /api/v1/im/usersig` | yes | 用户级 IM 登录票据,可在 App 启动后预取并按 TTL 复用 |
| 2 | Tencent IM SDK `login` | yes | 客户端先建立 IM 身份,房间群仍等 JoinRoom 成功后再进 |
| 3 | `GET /api/v1/rooms` | yes for discovery | 房间卡片返回 `im_group_id`,只用于展示和准备,不授予入群权限 |
| 4 | `POST /api/v1/rooms/join` | yes | 建立业务 presence并返回首屏房间数据和内嵌 RTC token |
| 5 | Tencent IM SDK `joinGroup(im_group_id)` | yes | 必须在 JoinRoom 成功后执行IM 回调守卫会校验 room-service presence |
| 6 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 使用 JoinRoom 响应里的 `rtc.token` 进入真实音频频道旁听 |
| 7 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence避免 stale timeout |
| 3 | `GET /api/v1/rooms/me` | yes for Mine top card | 直接读取 owner 房间权威元数据,不依赖发现页投影 |
| 4 | `GET /api/v1/rooms/feeds?tab=visited\|friend\|following` | yes for Mine lists | Mine 列表 tab`visited` 由 JoinRoom 成功写入,`friend/following` 读取关系流读模型 |
| 5 | `POST /api/v1/rooms/join` | yes | 建立业务 presence并返回首屏房间数据和内嵌 RTC token |
| 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 |
如果 JoinRoom 响应里的 `rtc.available=false`,客户端可以先展示房间页并重试 `POST /api/v1/rtc/token`RTC 失败不能回滚已经成功的业务 presence。

View File

@ -2,7 +2,7 @@
本文定义钱包服务的目标架构、账务模型、核心流程和落地顺序。当前 `wallet-service` 只有单币种 `coin` 扣费能力,新需求需要升级为多资产账户、外部支付订单、币商充值、主播奖励和提现审核的一体化账务边界。
资源、资源组、用户资源赠送和礼物从资源库选择的边界见 [Resource And Resource Group Architecture](./resource-group-gift-architecture.md)。本文只保留钱包账务和礼物价格结算边界。
资源、资源组、用户资源赠送和礼物从资源库选择的边界见 [Resource And Resource Group Architecture](./资源组与礼物架构.md)。本文只保留钱包账务和礼物价格结算边界。
## Goals

View File

@ -72,6 +72,9 @@ var catalog = map[Code]Spec{
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "conflict"),
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "conflict"),
VIPLevelNotFound: spec(codes.NotFound, httpStatusNotFound, VIPLevelNotFound, "not found"),
VIPLevelDisabled: spec(codes.FailedPrecondition, httpStatusConflict, VIPLevelDisabled, "conflict"),
VIPDowngradeNotAllowed: spec(codes.FailedPrecondition, httpStatusConflict, VIPDowngradeNotAllowed, "conflict"),
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),

View File

@ -88,6 +88,12 @@ const (
CoinSellerPaymentRefDuplicated Code = "COIN_SELLER_PAYMENT_REF_DUPLICATED"
// IdempotencyConflict 表示 command_id 相同但业务 payload 不一致。
IdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
// VIPLevelNotFound 表示目标 VIP 等级不存在。
VIPLevelNotFound Code = "VIP_LEVEL_NOT_FOUND"
// VIPLevelDisabled 表示目标 VIP 等级当前不可购买。
VIPLevelDisabled Code = "VIP_LEVEL_DISABLED"
// VIPDowngradeNotAllowed 表示用户当前有效 VIP 等级高于本次购买目标等级。
VIPDowngradeNotAllowed Code = "VIP_DOWNGRADE_NOT_ALLOWED"
// RuleNotActive 表示活动规则当前不可用。
RuleNotActive Code = "RULE_NOT_ACTIVE"

View File

@ -76,6 +76,7 @@ func New(cfg config.Config) (*App, error) {
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
var userHostClient client.UserHostClient = client.NewGRPCUserHostClient(userConn)
var userSocialClient client.UserSocialClient = client.NewGRPCUserSocialClient(userConn)
var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
@ -100,6 +101,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetUserDeviceClient(userDeviceClient)
handler.SetUserCountryQueryClient(userCountryQueryClient)
handler.SetUserHostClient(userHostClient)
handler.SetUserSocialClient(userSocialClient)
handler.SetAppRegistryClient(appRegistryClient)
handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient)

View File

@ -38,6 +38,8 @@ type RoomGuardClient interface {
// RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。
type RoomQueryClient interface {
ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error)
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)
}
@ -155,6 +157,14 @@ func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoo
return c.client.ListRooms(ctx, req)
}
func (c *grpcRoomQueryClient) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
return c.client.ListRoomFeeds(ctx, req)
}
func (c *grpcRoomQueryClient) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
return c.client.GetMyRoom(ctx, req)
}
func (c *grpcRoomQueryClient) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
return c.client.GetCurrentRoom(ctx, req)
}

View File

@ -29,12 +29,27 @@ type UserIdentityClient interface {
// UserProfileClient 抽象 gateway 对 user-service 用户资料能力的依赖。
type UserProfileClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error)
BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error)
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
}
// UserSocialClient 抽象 gateway 对 user-service 访问、关注和好友关系能力的依赖。
type UserSocialClient interface {
RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error)
ListProfileVisitors(ctx context.Context, req *userv1.ListProfileVisitorsRequest) (*userv1.ListProfileVisitorsResponse, error)
FollowUser(ctx context.Context, req *userv1.FollowUserRequest) (*userv1.FollowUserResponse, error)
UnfollowUser(ctx context.Context, req *userv1.UnfollowUserRequest) (*userv1.UnfollowUserResponse, error)
ListFollowing(ctx context.Context, req *userv1.ListFollowingRequest) (*userv1.ListFollowingResponse, error)
ApplyFriend(ctx context.Context, req *userv1.ApplyFriendRequest) (*userv1.ApplyFriendResponse, error)
AcceptFriendApplication(ctx context.Context, req *userv1.AcceptFriendApplicationRequest) (*userv1.AcceptFriendApplicationResponse, error)
DeleteFriend(ctx context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error)
ListFriends(ctx context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error)
}
// UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。
type UserDeviceClient interface {
BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error)
@ -51,6 +66,7 @@ type UserHostClient interface {
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error)
}
// AppRegistryClient 抽象 gateway 对 user-service App 注册表的解析依赖。
@ -70,6 +86,10 @@ type grpcUserProfileClient struct {
client userv1.UserServiceClient
}
type grpcUserSocialClient struct {
client userv1.UserSocialServiceClient
}
type grpcUserDeviceClient struct {
client userv1.UserDeviceServiceClient
}
@ -107,6 +127,13 @@ func NewGRPCUserProfileClient(conn *grpc.ClientConn) UserProfileClient {
}
}
// NewGRPCUserSocialClient 用 gRPC 连接构造 user-service social client。
func NewGRPCUserSocialClient(conn *grpc.ClientConn) UserSocialClient {
return &grpcUserSocialClient{
client: userv1.NewUserSocialServiceClient(conn),
}
}
// NewGRPCUserDeviceClient 用 gRPC 连接构造 user-service 设备推送 client。
func NewGRPCUserDeviceClient(conn *grpc.ClientConn) UserDeviceClient {
return &grpcUserDeviceClient{
@ -179,6 +206,10 @@ func (c *grpcUserProfileClient) GetUser(ctx context.Context, req *userv1.GetUser
return c.client.GetUser(ctx, req)
}
func (c *grpcUserProfileClient) GetMyProfileStats(ctx context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) {
return c.client.GetMyProfileStats(ctx, req)
}
func (c *grpcUserProfileClient) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
return c.client.BatchGetUsers(ctx, req)
}
@ -195,6 +226,46 @@ func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *user
return c.client.ChangeUserCountry(ctx, req)
}
func (c *grpcUserSocialClient) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
return c.client.RecordProfileVisit(ctx, req)
}
func (c *grpcUserSocialClient) ListProfileVisitors(ctx context.Context, req *userv1.ListProfileVisitorsRequest) (*userv1.ListProfileVisitorsResponse, error) {
return c.client.ListProfileVisitors(ctx, req)
}
func (c *grpcUserSocialClient) FollowUser(ctx context.Context, req *userv1.FollowUserRequest) (*userv1.FollowUserResponse, error) {
return c.client.FollowUser(ctx, req)
}
func (c *grpcUserSocialClient) UnfollowUser(ctx context.Context, req *userv1.UnfollowUserRequest) (*userv1.UnfollowUserResponse, error) {
return c.client.UnfollowUser(ctx, req)
}
func (c *grpcUserSocialClient) ListFollowing(ctx context.Context, req *userv1.ListFollowingRequest) (*userv1.ListFollowingResponse, error) {
return c.client.ListFollowing(ctx, req)
}
func (c *grpcUserSocialClient) ApplyFriend(ctx context.Context, req *userv1.ApplyFriendRequest) (*userv1.ApplyFriendResponse, error) {
return c.client.ApplyFriend(ctx, req)
}
func (c *grpcUserSocialClient) AcceptFriendApplication(ctx context.Context, req *userv1.AcceptFriendApplicationRequest) (*userv1.AcceptFriendApplicationResponse, error) {
return c.client.AcceptFriendApplication(ctx, req)
}
func (c *grpcUserSocialClient) DeleteFriend(ctx context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error) {
return c.client.DeleteFriend(ctx, req)
}
func (c *grpcUserSocialClient) ListFriends(ctx context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error) {
return c.client.ListFriends(ctx, req)
}
func (c *grpcUserSocialClient) ListFriendApplications(ctx context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error) {
return c.client.ListFriendApplications(ctx, req)
}
func (c *grpcUserDeviceClient) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
return c.client.BindPushToken(ctx, req)
}
@ -219,6 +290,10 @@ func (c *grpcUserHostClient) GetCoinSellerProfile(ctx context.Context, req *user
return c.client.GetCoinSellerProfile(ctx, req)
}
func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
return c.client.GetUserRoleSummary(ctx, req)
}
func (c *grpcAppRegistryClient) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest) (*userv1.ResolveAppResponse, error) {
return c.client.ResolveApp(ctx, req)
}

View File

@ -11,6 +11,15 @@ import (
// WalletClient 抽象 gateway 对 wallet-service 余额查询能力的依赖。
type WalletClient interface {
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error)
ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error)
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
ListResources(ctx context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
@ -32,6 +41,42 @@ func (c *grpcWalletClient) GetBalances(ctx context.Context, req *walletv1.GetBal
return c.client.GetBalances(ctx, req)
}
func (c *grpcWalletClient) GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
return c.client.GetWalletOverview(ctx, req)
}
func (c *grpcWalletClient) GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) {
return c.client.GetWalletValueSummary(ctx, req)
}
func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
return c.client.ListRechargeProducts(ctx, req)
}
func (c *grpcWalletClient) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
return c.client.GetDiamondExchangeConfig(ctx, req)
}
func (c *grpcWalletClient) ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
return c.client.ListWalletTransactions(ctx, req)
}
func (c *grpcWalletClient) ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
return c.client.ApplyWithdrawal(ctx, req)
}
func (c *grpcWalletClient) ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
return c.client.ListVipPackages(ctx, req)
}
func (c *grpcWalletClient) GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) {
return c.client.GetMyVip(ctx, req)
}
func (c *grpcWalletClient) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) {
return c.client.PurchaseVip(ctx, req)
}
func (c *grpcWalletClient) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
return c.client.TransferCoinFromSeller(ctx, req)
}

View File

@ -0,0 +1,350 @@
package http
import (
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
type rechargeProductData struct {
ProductID int64 `json:"product_id"`
ProductCode string `json:"product_code"`
Channel string `json:"channel"`
CurrencyCode string `json:"currency_code"`
CoinAmount int64 `json:"coin_amount"`
AmountMinor int64 `json:"amount_minor"`
PolicyVersion string `json:"policy_version"`
Enabled bool `json:"enabled"`
SortOrder int32 `json:"sort_order"`
}
type diamondExchangeRuleData struct {
ExchangeType string `json:"exchange_type"`
FromAssetType string `json:"from_asset_type"`
ToAssetType string `json:"to_asset_type"`
FromAmount int64 `json:"from_amount"`
ToAmount int64 `json:"to_amount"`
Enabled bool `json:"enabled"`
}
type walletOverviewData struct {
Balances []assetBalanceData `json:"balances"`
FeatureFlags walletFeatureFlagsData `json:"feature_flags"`
}
type walletFeatureFlagsData struct {
RechargeEnabled bool `json:"recharge_enabled"`
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
WithdrawEnabled bool `json:"withdraw_enabled"`
}
type walletTransactionData struct {
EntryID int64 `json:"entry_id"`
TransactionID string `json:"transaction_id"`
BizType string `json:"biz_type"`
AssetType string `json:"asset_type"`
AvailableDelta int64 `json:"available_delta"`
FrozenDelta int64 `json:"frozen_delta"`
AvailableAfter int64 `json:"available_after"`
FrozenAfter int64 `json:"frozen_after"`
CounterpartyUserID int64 `json:"counterparty_user_id"`
RoomID string `json:"room_id"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type withdrawalApplyRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
Amount int64 `json:"amount"`
PayoutAccount string `json:"payout_account"`
PayoutAlt string `json:"payoutAccount"`
Reason string `json:"reason"`
}
type withdrawalData struct {
WithdrawalID string `json:"withdrawal_id"`
UserID int64 `json:"user_id"`
AssetType string `json:"asset_type"`
Amount int64 `json:"amount"`
Status string `json:"status"`
PayoutAccount string `json:"payout_account"`
Reason string `json:"reason"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type vipPurchaseRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
Level int32 `json:"level"`
}
type vipPackageData struct {
Level int32 `json:"level"`
Name string `json:"name"`
Status string `json:"status"`
PriceCoin int64 `json:"price_coin"`
DurationMS int64 `json:"duration_ms"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardItems []vipRewardItemData `json:"reward_items"`
CanPurchase bool `json:"can_purchase"`
SortOrder int32 `json:"sort_order"`
}
type vipRewardItemData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
Quantity int64 `json:"quantity"`
ExpiresAtMS int64 `json:"expires_at_ms"`
}
// getWalletOverview 返回钱包二级页摘要;我的页首屏使用更轻的 WalletValueSummary。
func (h *Handler) getWalletOverview(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetWalletOverview(request.Context(), &walletv1.GetWalletOverviewRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, walletOverviewFromProto(resp))
}
// listRechargeProducts 返回用户当前区域可用的充值档位。
func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: userID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
resp, err := h.walletClient.ListRechargeProducts(request.Context(), &walletv1.ListRechargeProductsRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
products := make([]rechargeProductData, 0, len(resp.GetProducts()))
for _, product := range resp.GetProducts() {
products = append(products, rechargeProductFromProto(product))
}
writeOK(writer, request, map[string]any{"channels": resp.GetChannels(), "products": products})
}
// getDiamondExchangeConfig 返回钻石兑换配置。
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetDiamondExchangeConfig(request.Context(), &walletv1.GetDiamondExchangeConfigRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
rules := make([]diamondExchangeRuleData, 0, len(resp.GetRules()))
for _, rule := range resp.GetRules() {
rules = append(rules, diamondExchangeRuleFromProto(rule))
}
writeOK(writer, request, map[string]any{"rules": rules})
}
// listWalletTransactions 返回当前用户钱包流水分页。
func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
page, ok := parsePositiveInt32Query(request, "page", 1)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ListWalletTransactions(request.Context(), &walletv1.ListWalletTransactionsRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
AssetType: strings.TrimSpace(request.URL.Query().Get("asset_type")),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
items := make([]walletTransactionData, 0, len(resp.GetTransactions()))
for _, item := range resp.GetTransactions() {
items = append(items, walletTransactionFromProto(item))
}
writeOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
}
// applyWithdrawal 创建待人工审核的提现申请。
func (h *Handler) applyWithdrawal(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body withdrawalApplyRequestBody
if !decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
payoutAccount := strings.TrimSpace(body.PayoutAccount)
if payoutAccount == "" {
payoutAccount = strings.TrimSpace(body.PayoutAlt)
}
if commandID == "" || body.Amount <= 0 || payoutAccount == "" {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.ApplyWithdrawal(request.Context(), &walletv1.ApplyWithdrawalRequest{
CommandId: commandID,
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
Amount: body.Amount,
PayoutAccount: payoutAccount,
Reason: strings.TrimSpace(body.Reason),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{
"withdrawal": withdrawalFromProto(resp.GetWithdrawal()),
"balance": balanceFromProto(resp.GetBalance()),
})
}
func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProductData {
if product == nil {
return rechargeProductData{}
}
return rechargeProductData{
ProductID: product.GetProductId(),
ProductCode: product.GetProductCode(),
Channel: product.GetChannel(),
CurrencyCode: product.GetCurrencyCode(),
CoinAmount: product.GetCoinAmount(),
AmountMinor: product.GetAmountMinor(),
PolicyVersion: product.GetPolicyVersion(),
Enabled: product.GetEnabled(),
SortOrder: product.GetSortOrder(),
}
}
func diamondExchangeRuleFromProto(rule *walletv1.DiamondExchangeRule) diamondExchangeRuleData {
if rule == nil {
return diamondExchangeRuleData{}
}
return diamondExchangeRuleData{
ExchangeType: rule.GetExchangeType(),
FromAssetType: rule.GetFromAssetType(),
ToAssetType: rule.GetToAssetType(),
FromAmount: rule.GetFromAmount(),
ToAmount: rule.GetToAmount(),
Enabled: rule.GetEnabled(),
}
}
func walletOverviewFromProto(resp *walletv1.GetWalletOverviewResponse) walletOverviewData {
if resp == nil {
return walletOverviewData{}
}
balances := make([]assetBalanceData, 0, len(resp.GetBalances()))
for _, balance := range resp.GetBalances() {
balances = append(balances, balanceFromProto(balance))
}
flags := resp.GetFeatureFlags()
return walletOverviewData{
Balances: balances,
FeatureFlags: walletFeatureFlagsData{
RechargeEnabled: flags.GetRechargeEnabled(),
DiamondExchangeEnabled: flags.GetDiamondExchangeEnabled(),
WithdrawEnabled: flags.GetWithdrawEnabled(),
},
}
}
func walletTransactionFromProto(item *walletv1.WalletTransaction) walletTransactionData {
if item == nil {
return walletTransactionData{}
}
return walletTransactionData{
EntryID: item.GetEntryId(),
TransactionID: item.GetTransactionId(),
BizType: item.GetBizType(),
AssetType: item.GetAssetType(),
AvailableDelta: item.GetAvailableDelta(),
FrozenDelta: item.GetFrozenDelta(),
AvailableAfter: item.GetAvailableAfter(),
FrozenAfter: item.GetFrozenAfter(),
CounterpartyUserID: item.GetCounterpartyUserId(),
RoomID: item.GetRoomId(),
CreatedAtMS: item.GetCreatedAtMs(),
}
}
func withdrawalFromProto(item *walletv1.WithdrawalRequest) withdrawalData {
if item == nil {
return withdrawalData{}
}
return withdrawalData{
WithdrawalID: item.GetWithdrawalId(),
UserID: item.GetUserId(),
AssetType: item.GetAssetType(),
Amount: item.GetAmount(),
Status: item.GetStatus(),
PayoutAccount: item.GetPayoutAccount(),
Reason: item.GetReason(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func balanceFromProto(balance *walletv1.AssetBalance) assetBalanceData {
if balance == nil {
return assetBalanceData{}
}
return assetBalanceData{
AssetType: balance.GetAssetType(),
AvailableAmount: balance.GetAvailableAmount(),
FrozenAmount: balance.GetFrozenAmount(),
Version: balance.GetVersion(),
}
}

View File

@ -27,6 +27,7 @@ type Handler struct {
userClient client.UserAuthClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userSocialClient client.UserSocialClient
userDeviceClient client.UserDeviceClient
userCountryClient client.UserCountryQueryClient
userHostClient client.UserHostClient
@ -145,6 +146,11 @@ func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
h.userHostClient = userHostClient
}
// SetUserSocialClient 注入 user-service 访问、关注和好友关系 client。
func (h *Handler) SetUserSocialClient(userSocialClient client.UserSocialClient) {
h.userSocialClient = userSocialClient
}
// SetAppRegistryClient 注入 App 注册表解析 clientgateway 用它把包名转换成内部 app_code。
func (h *Handler) SetAppRegistryClient(appRegistryClient client.AppRegistryClient) {
h.appRegistryClient = appRegistryClient

View File

@ -77,6 +77,36 @@ func (h *Handler) getMyHostIdentity(writer http.ResponseWriter, request *http.Re
writeOK(writer, request, identity)
}
// getMyRoleSummary 返回我的页入口显隐所需的完整角色摘要。
func (h *Handler) getMyRoleSummary(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.userHostClient.GetUserRoleSummary(request.Context(), &userv1.GetUserRoleSummaryRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
summary := resp.GetSummary()
writeOK(writer, request, overviewRolesData{
IsHost: summary.GetIsHost(),
IsAgency: summary.GetIsAgency(),
IsBD: summary.GetIsBd(),
IsBDLeader: summary.GetIsBdLeader(),
IsCoinSeller: summary.GetIsCoinSeller(),
HostStatus: summary.GetHostStatus(),
AgencyID: summary.GetAgencyId(),
BDID: summary.GetBdId(),
BDStatus: summary.GetBdStatus(),
CoinSellerStatus: summary.GetCoinSellerStatus(),
PendingRoleInvitations: summary.GetPendingRoleInvitations(),
})
}
func applyHostIdentity(identity *userHostIdentityData, profile *userv1.HostProfile) {
if profile == nil || profile.GetStatus() != hostIdentityActiveStatus {
return

View File

@ -6,7 +6,6 @@ import (
"sync"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
@ -16,55 +15,62 @@ import (
const overviewAggregationTimeout = 200 * time.Millisecond
var overviewAssetTypes = []string{"COIN", "DIAMOND", "USD_BALANCE"}
type myOverviewData struct {
Profile userProfileData `json:"profile"`
Wallet overviewWalletData `json:"wallet"`
Invite overviewInviteData `json:"invite"`
Roles overviewRolesData `json:"roles"`
Badges overviewBadgesData `json:"badges"`
Entries []overviewEntryData `json:"entries"`
ServerTimeMS int64 `json:"server_time_ms"`
Profile userProfileData `json:"profile"`
ProfileStats overviewProfileStats `json:"profile_stats"`
Wallet overviewWalletData `json:"wallet"`
VIP overviewVIPData `json:"vip"`
Roles overviewRolesData `json:"roles"`
Entries []overviewEntryData `json:"entries"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type overviewWalletData struct {
Balances []assetBalanceData `json:"balances"`
RechargeEnabled bool `json:"recharge_enabled"`
DiamondExchangeEnabled bool `json:"diamond_exchange_enabled"`
WithdrawEnabled bool `json:"withdraw_enabled"`
Unavailable bool `json:"unavailable,omitempty"`
CoinAmount int64 `json:"coin_amount"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Unavailable bool `json:"unavailable,omitempty"`
}
type overviewRolesData struct {
IsHost bool `json:"is_host"`
IsAgent bool `json:"is_agent"`
IsBD bool `json:"is_bd"`
IsBDLeader bool `json:"is_bd_leader"`
IsCoinSeller bool `json:"is_coin_seller"`
Unavailable bool `json:"unavailable,omitempty"`
IsHost bool `json:"is_host"`
IsAgency bool `json:"is_agency"`
IsBD bool `json:"is_bd"`
IsBDLeader bool `json:"is_bd_leader"`
IsCoinSeller bool `json:"is_coin_seller"`
HostStatus string `json:"host_status,omitempty"`
AgencyID int64 `json:"agency_id,omitempty"`
BDID int64 `json:"bd_id,omitempty"`
BDStatus string `json:"bd_status,omitempty"`
CoinSellerStatus string `json:"coin_seller_status,omitempty"`
PendingRoleInvitations int64 `json:"pending_role_invitations"`
Unavailable bool `json:"unavailable,omitempty"`
}
type overviewInviteData struct {
MyInviteCode string `json:"my_invite_code"`
InviteEnabled bool `json:"invite_enabled"`
InviteCount int64 `json:"invite_count"`
ValidInviteCount int64 `json:"valid_invite_count"`
ValidInviteThresholdCoin int64 `json:"valid_invite_threshold_coin"`
}
type overviewBadgesData struct {
UnreadSystemMessages int64 `json:"unread_system_messages"`
UnreadActivityMessages int64 `json:"unread_activity_messages"`
PendingRoleInvitations int64 `json:"pending_role_invitations"`
Unavailable bool `json:"unavailable,omitempty"`
type overviewProfileStats struct {
VisitorsCount int64 `json:"visitors_count"`
FollowingCount int64 `json:"following_count"`
FriendsCount int64 `json:"friends_count"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Unavailable bool `json:"unavailable,omitempty"`
}
type overviewEntryData struct {
Key string `json:"key"`
Title string `json:"title"`
Visible bool `json:"visible"`
Enabled bool `json:"enabled"`
Route string `json:"route"`
IconKey string `json:"icon_key"`
BadgeCount int64 `json:"badge_count"`
Order int32 `json:"order"`
}
type overviewVIPData struct {
Level int32 `json:"level"`
Name string `json:"name"`
Active bool `json:"active"`
StartedAtMS int64 `json:"started_at_ms"`
ExpiresAtMS int64 `json:"expires_at_ms"`
Unavailable bool `json:"unavailable,omitempty"`
}
// getMyOverview 聚合 App 我的页首屏摘要gateway 只做 RPC 编排和入口显隐规则,不查数据库。
@ -91,7 +97,7 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
})
}()
var walletResp *walletv1.GetBalancesResponse
var walletResp *walletv1.GetWalletValueSummaryResponse
var walletErr error
wg.Add(1)
go func() {
@ -100,11 +106,26 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
walletErr = xerr.New(xerr.Unavailable, "wallet service is not configured")
return
}
walletResp, walletErr = h.walletClient.GetBalances(ctx, &walletv1.GetBalancesRequest{
RequestId: requestIDFromContext(request.Context()),
UserId: userID,
AssetTypes: append([]string(nil), overviewAssetTypes...),
AppCode: appcode.FromContext(request.Context()),
walletResp, walletErr = h.walletClient.GetWalletValueSummary(ctx, &walletv1.GetWalletValueSummaryRequest{
RequestId: requestIDFromContext(request.Context()),
UserId: userID,
AppCode: appcode.FromContext(request.Context()),
})
}()
var vipResp *walletv1.GetMyVipResponse
var vipErr error
wg.Add(1)
go func() {
defer wg.Done()
if h.walletClient == nil {
vipErr = xerr.New(xerr.Unavailable, "wallet service is not configured")
return
}
vipResp, vipErr = h.walletClient.GetMyVip(ctx, &walletv1.GetMyVipRequest{
RequestId: requestIDFromContext(request.Context()),
UserId: userID,
AppCode: appcode.FromContext(request.Context()),
})
}()
@ -116,17 +137,13 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
roles, rolesErr = h.getOverviewRoles(ctx, request, userID)
}()
var tabsResp *activityv1.ListMessageTabsResponse
var tabsErr error
var statsResp *userv1.GetMyProfileStatsResponse
var statsErr error
wg.Add(1)
go func() {
defer wg.Done()
if h.messageClient == nil {
tabsErr = xerr.New(xerr.Unavailable, "message service is not configured")
return
}
tabsResp, tabsErr = h.messageClient.ListMessageTabs(ctx, &activityv1.ListMessageTabsRequest{
Meta: activityMeta(request.WithContext(ctx)),
statsResp, statsErr = h.userProfileClient.GetMyProfileStats(ctx, &userv1.GetMyProfileStatsRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
}()
@ -145,173 +162,102 @@ func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Reques
roles = overviewRolesData{Unavailable: true}
}
wallet := overviewWallet(walletResp, walletErr)
badges := overviewBadges(tabsResp, tabsErr)
vip := overviewVIP(vipResp, vipErr)
profileStats := overviewStats(statsResp, statsErr)
writeOK(writer, request, myOverviewData{
Profile: profileData(profileResp.GetUser(), 0),
ProfileStats: profileStats,
Wallet: wallet,
Invite: overviewInvite(profileResp.GetUser().GetInvite()),
VIP: vip,
Roles: roles,
Badges: badges,
Entries: overviewEntries(roles),
ServerTimeMS: time.Now().UnixMilli(),
})
}
func overviewInvite(invite *userv1.InviteOverview) overviewInviteData {
if invite == nil {
return overviewInviteData{}
}
return overviewInviteData{
MyInviteCode: invite.GetMyInviteCode(),
InviteEnabled: invite.GetInviteEnabled(),
InviteCount: invite.GetInviteCount(),
ValidInviteCount: invite.GetValidInviteCount(),
ValidInviteThresholdCoin: invite.GetValidInviteThresholdCoin(),
}
}
func (h *Handler) getOverviewRoles(ctx context.Context, request *http.Request, userID int64) (overviewRolesData, error) {
if h.userHostClient == nil {
return overviewRolesData{}, xerr.New(xerr.Unavailable, "user host service is not configured")
}
var wg sync.WaitGroup
var hostProfile *userv1.HostProfile
var bdProfile *userv1.BDProfile
var coinSellerProfile *userv1.CoinSellerProfile
var roleErr error
var roleErrOnce sync.Once
setRoleErr := func(err error) {
if err == nil || xerr.ReasonFromGRPC(err) == xerr.NotFound {
return
}
roleErrOnce.Do(func() {
roleErr = err
})
resp, err := h.userHostClient.GetUserRoleSummary(ctx, &userv1.GetUserRoleSummaryRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
if err != nil {
return overviewRolesData{}, err
}
wg.Add(3)
go func() {
defer wg.Done()
resp, err := h.userHostClient.GetHostProfile(ctx, &userv1.GetHostProfileRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
if err != nil {
setRoleErr(err)
return
}
hostProfile = resp.GetHostProfile()
}()
go func() {
defer wg.Done()
resp, err := h.userHostClient.GetBDProfile(ctx, &userv1.GetBDProfileRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
if err != nil {
setRoleErr(err)
return
}
bdProfile = resp.GetBdProfile()
}()
go func() {
defer wg.Done()
resp, err := h.userHostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
if err != nil {
setRoleErr(err)
return
}
coinSellerProfile = resp.GetCoinSellerProfile()
}()
wg.Wait()
if roleErr != nil {
return overviewRolesData{}, roleErr
}
identity := userHostIdentityData{}
applyHostIdentity(&identity, hostProfile)
applyBDIdentity(&identity, bdProfile)
applyCoinSellerIdentity(&identity, coinSellerProfile)
summary := resp.GetSummary()
return overviewRolesData{
IsHost: identity.IsHost,
IsAgent: identity.IsAgent,
IsBD: identity.IsBD,
IsBDLeader: identity.IsBDLeader,
IsCoinSeller: identity.IsCoinSeller,
IsHost: summary.GetIsHost(),
IsAgency: summary.GetIsAgency(),
IsBD: summary.GetIsBd(),
IsBDLeader: summary.GetIsBdLeader(),
IsCoinSeller: summary.GetIsCoinSeller(),
HostStatus: summary.GetHostStatus(),
AgencyID: summary.GetAgencyId(),
BDID: summary.GetBdId(),
BDStatus: summary.GetBdStatus(),
CoinSellerStatus: summary.GetCoinSellerStatus(),
PendingRoleInvitations: summary.GetPendingRoleInvitations(),
}, nil
}
func overviewWallet(resp *walletv1.GetBalancesResponse, err error) overviewWalletData {
func overviewWallet(resp *walletv1.GetWalletValueSummaryResponse, err error) overviewWalletData {
if err != nil {
return overviewWalletData{
Balances: []assetBalanceData{},
Unavailable: true,
}
return overviewWalletData{Unavailable: true}
}
byType := make(map[string]*walletv1.AssetBalance)
for _, balance := range resp.GetBalances() {
if balance == nil {
continue
}
byType[balance.GetAssetType()] = balance
}
balances := make([]assetBalanceData, 0, len(overviewAssetTypes))
for _, assetType := range overviewAssetTypes {
balance := byType[assetType]
item := assetBalanceData{AssetType: assetType}
if balance != nil {
item.AvailableAmount = balance.GetAvailableAmount()
item.FrozenAmount = balance.GetFrozenAmount()
item.Version = balance.GetVersion()
}
balances = append(balances, item)
}
summary := resp.GetSummary()
return overviewWalletData{
Balances: balances,
RechargeEnabled: true,
DiamondExchangeEnabled: true,
WithdrawEnabled: true,
CoinAmount: summary.GetCoinAmount(),
UpdatedAtMS: summary.GetUpdatedAtMs(),
}
}
func overviewBadges(resp *activityv1.ListMessageTabsResponse, err error) overviewBadgesData {
func overviewVIP(resp *walletv1.GetMyVipResponse, err error) overviewVIPData {
if err != nil {
return overviewBadgesData{Unavailable: true}
return overviewVIPData{Unavailable: true}
}
vip := resp.GetVip()
return overviewVIPData{
Level: vip.GetLevel(),
Name: vip.GetName(),
Active: vip.GetActive(),
StartedAtMS: vip.GetStartedAtMs(),
ExpiresAtMS: vip.GetExpiresAtMs(),
}
}
var badges overviewBadgesData
for _, section := range resp.GetSections() {
switch section.GetSection() {
case "system":
badges.UnreadSystemMessages = section.GetUnreadCount()
case "activity":
badges.UnreadActivityMessages = section.GetUnreadCount()
}
func overviewStats(resp *userv1.GetMyProfileStatsResponse, err error) overviewProfileStats {
if err != nil {
return overviewProfileStats{Unavailable: true}
}
return overviewStatsFromProto(resp.GetStats())
}
func overviewStatsFromProto(stats *userv1.UserProfileStats) overviewProfileStats {
return overviewProfileStats{
VisitorsCount: stats.GetVisitorsCount(),
FollowingCount: stats.GetFollowingCount(),
FriendsCount: stats.GetFriendsCount(),
UpdatedAtMS: stats.GetUpdatedAtMs(),
}
return badges
}
func overviewEntries(roles overviewRolesData) []overviewEntryData {
return []overviewEntryData{
{Key: "wallet", Title: "Wallet", Visible: true},
{Key: "host_center", Title: "Host Center", Visible: roles.IsHost},
{Key: "apply_host", Title: "Apply Host", Visible: !roles.IsHost},
{Key: "agency_center", Title: "Agency Center", Visible: roles.IsAgent},
{Key: "bd_center", Title: "BD Center", Visible: roles.IsBD},
{Key: "bd_leader_center", Title: "BD Leader Center", Visible: roles.IsBDLeader},
{Key: "coin_seller_center", Title: "Coin Seller", Visible: roles.IsCoinSeller},
{Key: "backpack", Title: "Backpack", Visible: true},
{Key: "settings", Title: "Settings", Visible: true},
{Key: "support", Title: "Support", Visible: true},
{Key: "store", Visible: true, Enabled: true, Route: "/store", IconKey: "store", Order: 10},
{Key: "bag", Visible: true, Enabled: true, Route: "/bag", IconKey: "bag", Order: 20},
{Key: "level", Visible: true, Enabled: true, Route: "/level", IconKey: "level", Order: 30},
{Key: "task", Visible: true, Enabled: true, Route: "/task", IconKey: "task", Order: 40},
{Key: "apply_host", Visible: !roles.IsHost, Enabled: true, Route: "/host/apply", IconKey: "host_apply", Order: 50},
{Key: "host_center", Visible: roles.IsHost, Enabled: roles.IsHost, Route: "/host", IconKey: "host_center", Order: 60},
{Key: "agency_center", Visible: roles.IsAgency, Enabled: roles.IsAgency, Route: "/agency", IconKey: "agency", Order: 70},
{Key: "bd_center", Visible: roles.IsBD, Enabled: roles.IsBD, Route: "/bd", IconKey: "bd", Order: 80},
{Key: "bd_leader_center", Visible: roles.IsBDLeader, Enabled: roles.IsBDLeader, Route: "/bd-leader", IconKey: "bd_leader", Order: 90},
{Key: "coin_seller_center", Visible: roles.IsCoinSeller, Enabled: roles.IsCoinSeller, Route: "/coin-seller", IconKey: "coin_seller", Order: 100},
{Key: "settings", Visible: true, Enabled: true, Route: "/settings", IconKey: "settings", Order: 110},
{Key: "language", Visible: true, Enabled: true, Route: "/language", IconKey: "language", Order: 120},
}
}

View File

@ -7,31 +7,44 @@ import (
"net/http/httptest"
"testing"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
func TestGetMyOverviewComposesProfileWalletRolesBadgesAndEntries(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 10}
walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
{AssetType: "COIN", AvailableAmount: 12000, Version: 8},
{AssetType: "GIFT_POINT", AvailableAmount: 999},
{AssetType: "DIAMOND", AvailableAmount: 300, Version: 3},
{AssetType: "USD_BALANCE", AvailableAmount: 2500, FrozenAmount: 500, Version: 2},
}}}
hostClient := &fakeUserHostClient{
hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", Source: "admin_create_agency"},
bdProfile: &userv1.BDProfile{UserId: 42, Status: "active", Role: "bd_leader"},
profile: &userv1.CoinSellerProfile{UserId: 42, Status: "active", MerchantAssetType: "COIN_SELLER_COIN"},
func TestGetMyOverviewComposesProfileWalletVIPStatsRolesAndEntries(t *testing.T) {
profileClient := &fakeUserProfileClient{
regionID: 10,
statsResp: &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{
UserId: 42,
VisitorsCount: 1234,
FollowingCount: 2345,
FriendsCount: 3456,
UpdatedAtMs: 7000,
}},
}
messageClient := &fakeMessageInboxClient{tabsResp: &activityv1.ListMessageTabsResponse{Sections: []*activityv1.MessageTabSection{
{Section: "user", Title: "用户", UnreadCount: 99, Source: "tencent_im_sdk"},
{Section: "system", Title: "系统", UnreadCount: 2, Source: "backend"},
{Section: "activity", Title: "活动", UnreadCount: 1, Source: "backend"},
}}}
walletClient := &fakeWalletClient{
valueSummaryResp: &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: 12000, UpdatedAtMs: 8000}},
myVipResp: &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{UserId: 42, Level: 3, Name: "VIP3", Active: true, StartedAtMs: 9000, ExpiresAtMs: 19000}},
}
hostClient := &fakeUserHostClient{
roleSummary: &userv1.UserRoleSummary{
UserId: 42,
IsHost: true,
IsAgency: true,
IsBd: true,
IsBdLeader: true,
IsCoinSeller: true,
HostStatus: "active",
AgencyId: 7001,
BdId: 42,
BdStatus: "active",
CoinSellerStatus: "active",
PendingRoleInvitations: 4,
},
}
messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
@ -42,56 +55,61 @@ func TestGetMyOverviewComposesProfileWalletRolesBadgesAndEntries(t *testing.T) {
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || profileClient.lastGet.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("profile request mismatch: %+v", profileClient.lastGet)
}
if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetAppCode() != "lalu" {
t.Fatalf("wallet request mismatch: %+v", walletClient.last)
if profileClient.lastStats == nil || profileClient.lastStats.GetUserId() != 42 || profileClient.lastStats.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("profile stats request mismatch: %+v", profileClient.lastStats)
}
if got := walletClient.last.GetAssetTypes(); len(got) != 3 || got[0] != "COIN" || got[1] != "DIAMOND" || got[2] != "USD_BALANCE" {
t.Fatalf("overview wallet asset types mismatch: %+v", got)
if walletClient.lastValueSummary == nil || walletClient.lastValueSummary.GetUserId() != 42 || walletClient.lastValueSummary.GetAppCode() != "lalu" {
t.Fatalf("wallet value summary request mismatch: %+v", walletClient.lastValueSummary)
}
if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 || hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.last == nil || hostClient.last.GetUserId() != 42 {
t.Fatalf("role requests mismatch: host=%+v bd=%+v coin=%+v", hostClient.lastHost, hostClient.lastBD, hostClient.last)
if walletClient.lastMyVip == nil || walletClient.lastMyVip.GetUserId() != 42 || walletClient.lastMyVip.GetAppCode() != "lalu" {
t.Fatalf("vip request mismatch: %+v", walletClient.lastMyVip)
}
if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetRequestId() != "req-overview" {
t.Fatalf("message tabs request mismatch: %+v", messageClient.lastTabs)
if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastRoleSummary.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("role summary request mismatch: %+v", hostClient.lastRoleSummary)
}
if messageClient.lastTabs != nil {
t.Fatalf("my overview must not request message tabs: %+v", messageClient.lastTabs)
}
profile := data["profile"].(map[string]any)
if profile["user_id"] != "42" || profile["username"] != "hy" || profile["region_id"] != float64(10) {
t.Fatalf("profile data mismatch: %+v", profile)
}
stats := data["profile_stats"].(map[string]any)
if stats["visitors_count"] != float64(1234) || stats["following_count"] != float64(2345) || stats["friends_count"] != float64(3456) || stats["updated_at_ms"] != float64(7000) {
t.Fatalf("profile stats mismatch: %+v", stats)
}
roles := data["roles"].(map[string]any)
if roles["is_host"] != true || roles["is_agent"] != true || roles["is_bd"] != true || roles["is_bd_leader"] != true || roles["is_coin_seller"] != true {
if roles["is_host"] != true || roles["is_agency"] != true || roles["is_bd"] != true || roles["is_bd_leader"] != true || roles["is_coin_seller"] != true || roles["agency_id"] != float64(7001) {
t.Fatalf("roles data mismatch: %+v", roles)
}
badges := data["badges"].(map[string]any)
if badges["unread_system_messages"] != float64(2) || badges["unread_activity_messages"] != float64(1) || badges["pending_role_invitations"] != float64(0) {
t.Fatalf("badges data mismatch: %+v", badges)
}
wallet := data["wallet"].(map[string]any)
if wallet["recharge_enabled"] != true || wallet["diamond_exchange_enabled"] != true || wallet["withdraw_enabled"] != true {
t.Fatalf("wallet feature flags mismatch: %+v", wallet)
if wallet["coin_amount"] != float64(12000) || wallet["updated_at_ms"] != float64(8000) {
t.Fatalf("wallet value summary mismatch: %+v", wallet)
}
invite := data["invite"].(map[string]any)
if invite["my_invite_code"] != "A1B2C3" || invite["invite_enabled"] != true || invite["invite_count"] != float64(12) || invite["valid_invite_count"] != float64(3) || invite["valid_invite_threshold_coin"] != float64(80000) {
t.Fatalf("invite overview mismatch: %+v", invite)
vip := data["vip"].(map[string]any)
if vip["level"] != float64(3) || vip["name"] != "VIP3" || vip["active"] != true {
t.Fatalf("vip summary mismatch: %+v", vip)
}
balances := wallet["balances"].([]any)
if len(balances) != 3 {
t.Fatalf("overview must return three fixed balances, got %+v", balances)
if _, ok := data["invite"]; ok {
t.Fatalf("my overview must not include invite: %+v", data["invite"])
}
if balanceAssetType(balances[0]) != "COIN" || balanceAvailable(balances[0]) != 12000 ||
balanceAssetType(balances[1]) != "DIAMOND" || balanceAvailable(balances[1]) != 300 ||
balanceAssetType(balances[2]) != "USD_BALANCE" || balanceAvailable(balances[2]) != 2500 {
t.Fatalf("balance projection mismatch: %+v", balances)
if _, ok := data["badges"]; ok {
t.Fatalf("my overview must not include message badges: %+v", data["badges"])
}
entries := data["entries"].([]any)
if !overviewEntryVisible(entries, "wallet") ||
if !overviewEntryVisible(entries, "store") ||
!overviewEntryVisible(entries, "bag") ||
!overviewEntryVisible(entries, "level") ||
!overviewEntryVisible(entries, "task") ||
!overviewEntryVisible(entries, "host_center") ||
!overviewEntryVisible(entries, "agency_center") ||
!overviewEntryVisible(entries, "bd_center") ||
!overviewEntryVisible(entries, "bd_leader_center") ||
!overviewEntryVisible(entries, "coin_seller_center") ||
!overviewEntryVisible(entries, "settings") ||
!overviewEntryVisible(entries, "language") ||
overviewEntryVisible(entries, "apply_host") {
t.Fatalf("entry visibility mismatch: %+v", entries)
}
@ -100,24 +118,21 @@ func TestGetMyOverviewComposesProfileWalletRolesBadgesAndEntries(t *testing.T) {
}
}
func TestGetMyOverviewFillsMissingWalletBalancesForOrdinaryUser(t *testing.T) {
func TestGetMyOverviewProjectsOrdinaryUserEntriesAndZeroWallet(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(&fakeWalletClient{resp: &walletv1.GetBalancesResponse{}})
handler.SetWalletClient(&fakeWalletClient{})
handler.SetUserHostClient(&fakeUserHostClient{})
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
data := performOverviewRequest(t, handler, "req-overview-empty-wallet")
wallet := data["wallet"].(map[string]any)
balances := wallet["balances"].([]any)
if len(balances) != 3 {
t.Fatalf("missing wallet rows must still project three balances: %+v", balances)
if wallet["coin_amount"] != float64(0) || wallet["updated_at_ms"] != float64(0) {
t.Fatalf("missing wallet summary should be zero projection: %+v", wallet)
}
for _, balance := range balances {
item := balance.(map[string]any)
if item["available_amount"] != float64(0) || item["frozen_amount"] != float64(0) || item["version"] != float64(0) {
t.Fatalf("missing balance should be zero projection: %+v", item)
}
stats := data["profile_stats"].(map[string]any)
if stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["friends_count"] != float64(0) {
t.Fatalf("missing stats should be zero projection: %+v", stats)
}
roles := data["roles"].(map[string]any)
if roles["is_host"] != false || roles["is_bd"] != false || roles["is_coin_seller"] != false {
@ -130,25 +145,25 @@ func TestGetMyOverviewFillsMissingWalletBalancesForOrdinaryUser(t *testing.T) {
}
func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{statsErr: errors.New("stats down")})
handler.SetWalletClient(&fakeWalletClient{err: errors.New("wallet down")})
handler.SetUserHostClient(&fakeUserHostClient{hostErr: xerr.ToGRPCError(xerr.New(xerr.Internal, "role service down"))})
handler.SetMessageInboxClient(&fakeMessageInboxClient{err: errors.New("message down")})
handler.SetUserHostClient(&fakeUserHostClient{roleSummaryErr: xerr.ToGRPCError(xerr.New(xerr.Internal, "role service down"))})
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
data := performOverviewRequest(t, handler, "req-overview-degraded")
wallet := data["wallet"].(map[string]any)
if wallet["unavailable"] != true || len(wallet["balances"].([]any)) != 0 {
if wallet["unavailable"] != true || wallet["coin_amount"] != float64(0) {
t.Fatalf("wallet degradation mismatch: %+v", wallet)
}
stats := data["profile_stats"].(map[string]any)
if stats["unavailable"] != true || stats["visitors_count"] != float64(0) || stats["following_count"] != float64(0) || stats["friends_count"] != float64(0) {
t.Fatalf("profile stats degradation mismatch: %+v", stats)
}
roles := data["roles"].(map[string]any)
if roles["unavailable"] != true || roles["is_host"] != false || roles["is_bd"] != false || roles["is_coin_seller"] != false {
t.Fatalf("roles degradation mismatch: %+v", roles)
}
badges := data["badges"].(map[string]any)
if badges["unavailable"] != true || badges["unread_system_messages"] != float64(0) || badges["unread_activity_messages"] != float64(0) {
t.Fatalf("badges degradation mismatch: %+v", badges)
}
entries := data["entries"].([]any)
if overviewEntryVisible(entries, "host_center") || overviewEntryVisible(entries, "bd_center") || overviewEntryVisible(entries, "coin_seller_center") {
t.Fatalf("degraded roles must not expose privileged entries: %+v", entries)
@ -161,7 +176,6 @@ func TestGetMyOverviewFailsWhenProfileFails(t *testing.T) {
})
handler.SetWalletClient(&fakeWalletClient{})
handler.SetUserHostClient(&fakeUserHostClient{})
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/overview", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
@ -208,15 +222,3 @@ func overviewEntryVisible(entries []any, key string) bool {
}
return false
}
func balanceAssetType(balance any) string {
item, _ := balance.(map[string]any)
value, _ := item["asset_type"].(string)
return value
}
func balanceAvailable(balance any) int64 {
item, _ := balance.(map[string]any)
value, _ := item["available_amount"].(float64)
return int64(value)
}

View File

@ -190,12 +190,15 @@ type fakeUserAuthClient struct {
type fakeUserProfileClient struct {
lastGet *userv1.GetUserRequest
lastStats *userv1.GetMyProfileStatsRequest
lastBatch *userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
lastCountry *userv1.ChangeUserCountryRequest
getErr error
statsResp *userv1.GetMyProfileStatsResponse
statsErr error
completeErr error
countryErr error
regionID int64
@ -210,12 +213,18 @@ type fakeUserIdentityClient struct {
type fakeRoomQueryClient struct {
lastList *roomv1.ListRoomsRequest
lastFeeds *roomv1.ListRoomFeedsRequest
lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
resp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
err error
feedsErr error
myRoomErr error
currentErr error
snapshotErr error
}
@ -234,24 +243,48 @@ type fakeUserCountryQueryClient struct {
}
type fakeUserHostClient struct {
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
}
type fakeWalletClient struct {
last *walletv1.GetBalancesRequest
resp *walletv1.GetBalancesResponse
err error
lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error
last *walletv1.GetBalancesRequest
resp *walletv1.GetBalancesResponse
err error
lastOverview *walletv1.GetWalletOverviewRequest
overviewResp *walletv1.GetWalletOverviewResponse
overviewErr error
lastValueSummary *walletv1.GetWalletValueSummaryRequest
valueSummaryResp *walletv1.GetWalletValueSummaryResponse
valueSummaryErr error
lastRechargeProducts *walletv1.ListRechargeProductsRequest
rechargeProductsResp *walletv1.ListRechargeProductsResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest
transactionsResp *walletv1.ListWalletTransactionsResponse
lastWithdrawal *walletv1.ApplyWithdrawalRequest
withdrawalResp *walletv1.ApplyWithdrawalResponse
lastVipPackages *walletv1.ListVipPackagesRequest
vipPackagesResp *walletv1.ListVipPackagesResponse
lastMyVip *walletv1.GetMyVipRequest
myVipResp *walletv1.GetMyVipResponse
vipErr error
lastPurchaseVip *walletv1.PurchaseVipRequest
purchaseVipResp *walletv1.PurchaseVipResponse
lastTransfer *walletv1.TransferCoinFromSellerRequest
transferResp *walletv1.TransferCoinFromSellerResponse
transferErr error
}
type fakeMessageInboxClient struct {
@ -372,6 +405,17 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
}}, nil
}
func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) {
f.lastStats = req
if f.statsErr != nil {
return nil, f.statsErr
}
if f.statsResp != nil {
return f.statsResp, nil
}
return &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{UserId: req.GetUserId()}}, nil
}
func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
f.lastBatch = req
users := make(map[int64]*userv1.User, len(req.GetUserIds()))
@ -504,6 +548,31 @@ func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRooms
return &roomv1.ListRoomsResponse{}, nil
}
func (f *fakeRoomQueryClient) ListRoomFeeds(_ context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
f.lastFeeds = req
if f.feedsErr != nil {
return nil, f.feedsErr
}
if f.feedsResp != nil {
return f.feedsResp, nil
}
if f.resp != nil {
return f.resp, nil
}
return &roomv1.ListRoomsResponse{}, nil
}
func (f *fakeRoomQueryClient) GetMyRoom(_ context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
f.lastMyRoom = req
if f.myRoomErr != nil {
return nil, f.myRoomErr
}
if f.myRoomResp != nil {
return f.myRoomResp, nil
}
return &roomv1.GetMyRoomResponse{}, nil
}
func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
f.lastCurrent = req
if f.currentErr != nil {
@ -675,6 +744,17 @@ func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1
return &userv1.GetCoinSellerProfileResponse{}, nil
}
func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
f.lastRoleSummary = req
if f.roleSummaryErr != nil {
return nil, f.roleSummaryErr
}
if f.roleSummary != nil {
return &userv1.GetUserRoleSummaryResponse{Summary: f.roleSummary}, nil
}
return &userv1.GetUserRoleSummaryResponse{Summary: &userv1.UserRoleSummary{UserId: req.GetUserId()}}, nil
}
func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
f.last = req
if f.err != nil {
@ -687,6 +767,131 @@ func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalan
return &walletv1.GetBalancesResponse{}, nil
}
func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
f.lastOverview = req
if f.overviewErr != nil {
return nil, f.overviewErr
}
if f.overviewResp != nil {
return f.overviewResp, nil
}
if f.err != nil {
return nil, f.err
}
balances := []*walletv1.AssetBalance(nil)
if f.resp != nil {
balances = f.resp.GetBalances()
}
return &walletv1.GetWalletOverviewResponse{
Balances: balances,
FeatureFlags: &walletv1.WalletFeatureFlags{
RechargeEnabled: true,
DiamondExchangeEnabled: true,
WithdrawEnabled: true,
},
}, nil
}
func (f *fakeWalletClient) GetWalletValueSummary(_ context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) {
f.lastValueSummary = req
if f.valueSummaryErr != nil {
return nil, f.valueSummaryErr
}
if f.valueSummaryResp != nil {
return f.valueSummaryResp, nil
}
if f.err != nil {
return nil, f.err
}
var coinAmount int64
if f.resp != nil {
for _, balance := range f.resp.GetBalances() {
if balance.GetAssetType() == "COIN" {
coinAmount = balance.GetAvailableAmount()
break
}
}
}
return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: coinAmount}}, nil
}
func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
f.lastRechargeProducts = req
if f.err != nil {
return nil, f.err
}
if f.rechargeProductsResp != nil {
return f.rechargeProductsResp, nil
}
return &walletv1.ListRechargeProductsResponse{}, nil
}
func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
f.lastDiamondExchange = req
if f.err != nil {
return nil, f.err
}
if f.diamondExchangeResp != nil {
return f.diamondExchangeResp, nil
}
return &walletv1.GetDiamondExchangeConfigResponse{}, nil
}
func (f *fakeWalletClient) ListWalletTransactions(_ context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
f.lastTransactions = req
if f.err != nil {
return nil, f.err
}
if f.transactionsResp != nil {
return f.transactionsResp, nil
}
return &walletv1.ListWalletTransactionsResponse{}, nil
}
func (f *fakeWalletClient) ApplyWithdrawal(_ context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
f.lastWithdrawal = req
if f.err != nil {
return nil, f.err
}
if f.withdrawalResp != nil {
return f.withdrawalResp, nil
}
return &walletv1.ApplyWithdrawalResponse{}, nil
}
func (f *fakeWalletClient) ListVipPackages(_ context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
f.lastVipPackages = req
if f.vipErr != nil {
return nil, f.vipErr
}
if f.vipPackagesResp != nil {
return f.vipPackagesResp, nil
}
return &walletv1.ListVipPackagesResponse{}, nil
}
func (f *fakeWalletClient) GetMyVip(_ context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) {
f.lastMyVip = req
if f.vipErr != nil {
return nil, f.vipErr
}
if f.myVipResp != nil {
return f.myVipResp, nil
}
return &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{UserId: req.GetUserId()}}, nil
}
func (f *fakeWalletClient) PurchaseVip(_ context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) {
f.lastPurchaseVip = req
if f.vipErr != nil {
return nil, f.vipErr
}
if f.purchaseVipResp != nil {
return f.purchaseVipResp, nil
}
return &walletv1.PurchaseVipResponse{}, nil
}
func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
f.lastTransfer = req
if f.transferErr != nil {
@ -1097,7 +1302,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=me&limit=2&cursor=cursor-1&q=room&visible_region_id=9999", nil)
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&cursor=cursor-1&q=room&visible_region_id=9999", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-list")
recorder := httptest.NewRecorder()
@ -1116,7 +1321,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 {
t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList)
}
if queryClient.lastList.GetTab() != "me" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" {
if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" {
t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
}
var response responseEnvelope
@ -1131,6 +1336,98 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
}
}
func TestListRoomsRejectsMineFeedTabs(t *testing.T) {
for _, tab := range []string{"visited", "friend", "following", "me"} {
t.Run(tab, func(t *testing.T) {
queryClient := &fakeRoomQueryClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab="+tab+"&limit=3", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if queryClient.lastList != nil || queryClient.lastFeeds != nil {
t.Fatalf("invalid public list tab must not reach room-service: list=%+v feeds=%+v", queryClient.lastList, queryClient.lastFeeds)
}
})
}
}
func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) {
for _, tab := range []string{"visited", "friend", "following"} {
t.Run(tab, func(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
queryClient := &fakeRoomQueryClient{feedsResp: &roomv1.ListRoomsResponse{
Rooms: []*roomv1.RoomListItem{{RoomId: "room-feed", VisibleRegionId: 1001, Heat: 7}},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab="+tab+"&limit=3", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-feed-"+tab)
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 queryClient.lastFeeds == nil || queryClient.lastFeeds.GetTab() != tab || queryClient.lastFeeds.GetViewerUserId() != 42 || queryClient.lastFeeds.GetVisibleRegionId() != 1001 {
t.Fatalf("feed tab was not forwarded with authenticated user and region: %+v", queryClient.lastFeeds)
}
})
}
}
func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) {
queryClient := &fakeRoomQueryClient{myRoomResp: &roomv1.GetMyRoomResponse{
HasRoom: true,
Room: &roomv1.RoomListItem{
RoomId: "room-owner",
OwnerUserId: 42,
Title: "Owner Room",
Status: "active",
SeatCount: 8,
},
ServerTimeMs: 1_700_000_000_123,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/me", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-my-room")
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 queryClient.lastMyRoom == nil || queryClient.lastMyRoom.GetOwnerUserId() != 42 {
t.Fatalf("my room must use authenticated owner: %+v", queryClient.lastMyRoom)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode my room response failed: %v", err)
}
data := response.Data.(map[string]any)
if data["has_room"] != true {
t.Fatalf("my room must preserve has_room=true: %+v", data)
}
room := data["room"].(map[string]any)
if room["room_id"] != "room-owner" || room["im_group_id"] != "room-owner" {
t.Fatalf("my room must expose room id and im group id: %+v", room)
}
}
func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
queryClient := &fakeRoomQueryClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})

View File

@ -25,6 +25,11 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
tab, ok := parsePublicRoomListTab(request.URL.Query().Get("tab"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
viewerUserID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
@ -41,7 +46,7 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
Meta: meta(request, "", ""),
ViewerUserId: viewerUserID,
VisibleRegionId: userResp.GetUser().GetRegionId(),
Tab: request.URL.Query().Get("tab"),
Tab: tab,
Cursor: request.URL.Query().Get("cursor"),
Limit: limit,
Query: roomListQuery(request),
@ -53,6 +58,75 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
writeOK(writer, request, roomListDataFromProto(resp))
}
// listRoomFeeds 查询 Mine 页 visited/friend/following 用户房间流。
func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil || h.roomQueryClient == nil {
// feed 同样需要 user-service 区域归属,避免跨区域露出用户关系流房间。
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
tab, ok := parseRoomFeedTab(request.URL.Query().Get("tab"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
viewerUserID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: authRequestMeta(request, ""),
UserId: viewerUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
resp, err := h.roomQueryClient.ListRoomFeeds(request.Context(), &roomv1.ListRoomFeedsRequest{
Meta: meta(request, "", ""),
ViewerUserId: viewerUserID,
VisibleRegionId: userResp.GetUser().GetRegionId(),
Tab: tab,
Cursor: request.URL.Query().Get("cursor"),
Limit: limit,
Query: roomListQuery(request),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, roomListDataFromProto(resp))
}
func parsePublicRoomListTab(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "hot":
return "hot", true
case "new":
return "new", true
default:
return "", false
}
}
func parseRoomFeedTab(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "visited":
return "visited", true
case "friend":
return "friend", true
case "following":
return "following", true
default:
return "", false
}
}
func roomListQuery(request *http.Request) string {
if query := strings.TrimSpace(request.URL.Query().Get("q")); query != "" {
return query
@ -77,6 +151,25 @@ func parseRoomListLimit(raw string) (int32, bool) {
return int32(value), true
}
// getMyRoom 返回 Mine 顶部“我的房间”卡片。
// 这个入口直接查 room-service 的 owner 权威数据,不依赖发现页 room_list_entries 投影是否命中。
func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.roomQueryClient.GetMyRoom(request.Context(), &roomv1.GetMyRoomRequest{
Meta: meta(request, "", ""),
OwnerUserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, myRoomDataFromProto(resp))
}
// getCurrentRoom 查询当前登录用户是否仍有可恢复房间。
// gateway 只传鉴权 user_idpresence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。
func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) {

View File

@ -15,6 +15,12 @@ type roomListData struct {
NextCursor string `json:"next_cursor,omitempty"`
}
type myRoomData struct {
HasRoom bool `json:"has_room"`
Room *roomListItemData `json:"room,omitempty"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type roomListItemData struct {
RoomID string `json:"room_id"`
IMGroupID string `json:"im_group_id"`
@ -118,28 +124,47 @@ func roomListDataFromProto(resp *roomv1.ListRoomsResponse) roomListData {
}
items := make([]roomListItemData, 0, len(resp.GetRooms()))
for _, room := range resp.GetRooms() {
roomID := room.GetRoomId()
items = append(items, roomListItemData{
RoomID: roomID,
IMGroupID: roomIMGroupID(roomID),
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
HostUserID: formatOptionalUserID(room.GetHostUserId()),
Title: room.GetTitle(),
CoverURL: room.GetCoverUrl(),
Mode: room.GetMode(),
Status: room.GetStatus(),
Heat: room.GetHeat(),
OnlineCount: room.GetOnlineCount(),
SeatCount: room.GetSeatCount(),
OccupiedSeatCount: room.GetOccupiedSeatCount(),
VisibleRegionID: room.GetVisibleRegionId(),
AppCode: room.GetAppCode(),
RoomShortID: room.GetRoomShortId(),
})
items = append(items, roomListItemDataFromProto(room))
}
return roomListData{Rooms: items, NextCursor: resp.GetNextCursor()}
}
func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData {
if resp == nil {
return myRoomData{}
}
data := myRoomData{HasRoom: resp.GetHasRoom(), ServerTimeMS: resp.GetServerTimeMs()}
if resp.GetHasRoom() && resp.GetRoom() != nil {
room := roomListItemDataFromProto(resp.GetRoom())
data.Room = &room
}
return data
}
func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
if room == nil {
return roomListItemData{}
}
roomID := room.GetRoomId()
return roomListItemData{
RoomID: roomID,
IMGroupID: roomIMGroupID(roomID),
OwnerUserID: formatOptionalUserID(room.GetOwnerUserId()),
HostUserID: formatOptionalUserID(room.GetHostUserId()),
Title: room.GetTitle(),
CoverURL: room.GetCoverUrl(),
Mode: room.GetMode(),
Status: room.GetStatus(),
Heat: room.GetHeat(),
OnlineCount: room.GetOnlineCount(),
SeatCount: room.GetSeatCount(),
OccupiedSeatCount: room.GetOccupiedSeatCount(),
VisibleRegionID: room.GetVisibleRegionId(),
AppCode: room.GetAppCode(),
RoomShortID: room.GetRoomShortId(),
}
}
func commandResultDataFromProto(result *roomv1.CommandResult) roomCommandResultData {
if result == nil {
return roomCommandResultData{}

View File

@ -36,17 +36,25 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/users/by-display-user-id/", h.publicAPIHandler(h.resolveDisplayUserID))
mux.Handle(apiV1Prefix+"/users/profiles:batch", h.profileAPIHandler(jwtVerifier, h.batchUserProfiles))
mux.Handle(apiV1Prefix+"/users/me/overview", h.profileAPIHandler(jwtVerifier, h.getMyOverview))
mux.Handle(apiV1Prefix+"/users/me", h.profileAPIHandler(jwtVerifier, h.getMyProfile))
mux.Handle(apiV1Prefix+"/users/me/identity", h.apiHandler(jwtVerifier, h.getMyIdentity))
mux.Handle(apiV1Prefix+"/users/me/host-identity", h.profileAPIHandler(jwtVerifier, h.getMyHostIdentity))
mux.Handle(apiV1Prefix+"/users/me/role-summary", h.profileAPIHandler(jwtVerifier, h.getMyRoleSummary))
mux.Handle(apiV1Prefix+"/users/me/onboarding/complete", h.apiHandler(jwtVerifier, h.completeMyOnboarding))
mux.Handle(apiV1Prefix+"/users/me/profile/update", h.profileAPIHandler(jwtVerifier, h.updateMyProfile))
mux.Handle(apiV1Prefix+"/users/me/country/change", h.profileAPIHandler(jwtVerifier, h.changeMyCountry))
mux.Handle(apiV1Prefix+"/users/me/display-id/change", h.apiHandler(jwtVerifier, h.changeMyDisplayUserID))
mux.Handle(apiV1Prefix+"/users/me/display-id/pretty/apply", h.apiHandler(jwtVerifier, h.applyMyPrettyDisplayUserID))
mux.Handle(apiV1Prefix+"/users/me/visitors", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyProfileVisitors)))
mux.Handle(apiV1Prefix+"/users/me/following", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFollowing)))
mux.Handle(apiV1Prefix+"/users/me/friends", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFriends)))
mux.Handle(apiV1Prefix+"/users/me/friend-requests", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listMyFriendApplications)))
mux.Handle(apiV1Prefix+"/users/me", h.profileAPIHandler(jwtVerifier, h.getMyProfile))
mux.Handle(apiV1Prefix+"/users/me/resources", h.profileAPIHandler(jwtVerifier, h.listMyResources))
mux.Handle(apiV1Prefix+"/users/me/resources/", h.profileAPIHandler(jwtVerifier, h.equipMyResource))
mux.Handle(apiV1Prefix+"/users/", h.profileAPIHandler(jwtVerifier, h.userSocialAction))
mux.Handle(apiV1Prefix+"/rooms/me", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getMyRoom)))
mux.Handle(apiV1Prefix+"/rooms/feeds", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRoomFeeds)))
mux.Handle(apiV1Prefix+"/rooms", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRooms)))
mux.Handle(apiV1Prefix+"/rooms/current", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getCurrentRoom)))
mux.Handle(apiV1Prefix+"/rooms/snapshot", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getRoomSnapshot)))
@ -73,8 +81,16 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/messages", h.profileAPIHandler(jwtVerifier, h.listInboxMessages))
mux.Handle(apiV1Prefix+"/tasks/tabs", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listTaskTabs)))
mux.Handle(apiV1Prefix+"/tasks/claim", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.claimTaskReward)))
mux.Handle(apiV1Prefix+"/wallet/me/overview", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getWalletOverview)))
mux.Handle(apiV1Prefix+"/wallet/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
mux.Handle(apiV1Prefix+"/wallet/recharge/products", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listRechargeProducts)))
mux.Handle(apiV1Prefix+"/wallet/diamond-exchange/config", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getDiamondExchangeConfig)))
mux.Handle(apiV1Prefix+"/wallet/withdrawals/apply", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.applyWithdrawal)))
mux.Handle(apiV1Prefix+"/wallet/transactions", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listWalletTransactions)))
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))
mux.Handle(apiV1Prefix+"/vip/me", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.getMyVIP)))
mux.Handle(apiV1Prefix+"/vip/packages", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.listVIPPackages)))
mux.Handle(apiV1Prefix+"/vip/purchase", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.purchaseVIP)))
return mux
}

View File

@ -0,0 +1,352 @@
package http
import (
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
)
type profileVisitRecordData struct {
VisitorUserID string `json:"visitor_user_id"`
TargetUserID string `json:"target_user_id"`
VisitCount int64 `json:"visit_count"`
LastVisitedAtMS int64 `json:"last_visited_at_ms"`
}
type followRecordData struct {
FollowerUserID string `json:"follower_user_id"`
FolloweeUserID string `json:"followee_user_id"`
FollowedAtMS int64 `json:"followed_at_ms"`
}
type friendRecordData struct {
UserID string `json:"user_id"`
FriendUserID string `json:"friend_user_id"`
FriendedAtMS int64 `json:"friended_at_ms"`
}
type friendApplicationData struct {
RequesterUserID string `json:"requester_user_id"`
TargetUserID string `json:"target_user_id"`
Status string `json:"status"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// listMyProfileVisitors 返回访问我的主页的人;这是关系 read model不实时扫用户行为日志。
func (h *Handler) listMyProfileVisitors(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListProfileVisitors(request.Context(), &userv1.ListProfileVisitorsRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
records := make([]profileVisitRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, profileVisitData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFollowing 返回我关注的用户列表;关注我的列表后续单独建接口,避免语义混淆。
func (h *Handler) listMyFollowing(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
records := make([]followRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, followData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriends 返回双向好友列表。
func (h *Handler) listMyFriends(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
records := make([]friendRecordData, 0, len(resp.GetRecords()))
for _, record := range resp.GetRecords() {
records = append(records, friendData(record))
}
writeOK(writer, request, map[string]any{"records": records, "total": resp.GetTotal()})
}
// listMyFriendApplications 返回待处理申请direction=incoming/outgoing。
func (h *Handler) listMyFriendApplications(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
page, pageSize, ok := socialPage(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.ListFriendApplications(request.Context(), &userv1.ListFriendApplicationsRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Direction: strings.TrimSpace(request.URL.Query().Get("direction")),
Page: page,
PageSize: pageSize,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
applications := make([]friendApplicationData, 0, len(resp.GetApplications()))
for _, application := range resp.GetApplications() {
applications = append(applications, friendApplicationDataFromProto(application))
}
writeOK(writer, request, map[string]any{"applications": applications, "total": resp.GetTotal()})
}
// userSocialAction 处理 /users/{user_id}/visit|follow|friend-requests|friend-requests/accept|friend。
func (h *Handler) userSocialAction(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
targetUserID, action, ok := parseUserSocialActionPath(request.URL.Path)
if !ok {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
switch action {
case "visit":
h.recordProfileVisit(writer, request, targetUserID)
case "follow":
h.handleFollowAction(writer, request, targetUserID)
case "friend-requests":
h.applyFriend(writer, request, targetUserID)
case "friend-requests/accept":
h.acceptFriendApplication(writer, request, targetUserID)
case "friend":
h.deleteFriend(writer, request, targetUserID)
default:
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
}
}
func (h *Handler) recordProfileVisit(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
resp, err := h.userSocialClient.RecordProfileVisit(request.Context(), &userv1.RecordProfileVisitRequest{
Meta: authRequestMeta(request, ""),
VisitorUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"recorded": resp.GetRecorded(), "target_stats": overviewStatsFromProto(resp.GetTargetStats())})
}
func (h *Handler) handleFollowAction(writer http.ResponseWriter, request *http.Request, followeeUserID int64) {
userID := auth.UserIDFromContext(request.Context())
switch request.Method {
case http.MethodPost:
resp, err := h.userSocialClient.FollowUser(request.Context(), &userv1.FollowUserRequest{
Meta: authRequestMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
case http.MethodDelete:
resp, err := h.userSocialClient.UnfollowUser(request.Context(), &userv1.UnfollowUserRequest{
Meta: authRequestMeta(request, ""),
FollowerUserId: userID,
FolloweeUserId: followeeUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"following": resp.GetFollowing(), "follower_stats": overviewStatsFromProto(resp.GetFollowerStats())})
default:
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
}
}
func (h *Handler) applyFriend(writer http.ResponseWriter, request *http.Request, targetUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
resp, err := h.userSocialClient.ApplyFriend(request.Context(), &userv1.ApplyFriendRequest{
Meta: authRequestMeta(request, ""),
RequesterUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"application": friendApplicationDataFromProto(resp.GetApplication()), "already_friends": resp.GetAlreadyFriends()})
}
func (h *Handler) acceptFriendApplication(writer http.ResponseWriter, request *http.Request, requesterUserID int64) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
resp, err := h.userSocialClient.AcceptFriendApplication(request.Context(), &userv1.AcceptFriendApplicationRequest{
Meta: authRequestMeta(request, ""),
AccepterUserId: auth.UserIDFromContext(request.Context()),
RequesterUserId: requesterUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"friend": friendData(resp.GetFriend())})
}
func (h *Handler) deleteFriend(writer http.ResponseWriter, request *http.Request, friendUserID int64) {
if request.Method != http.MethodDelete {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
resp, err := h.userSocialClient.DeleteFriend(request.Context(), &userv1.DeleteFriendRequest{
Meta: authRequestMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
FriendUserId: friendUserID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"deleted": resp.GetDeleted()})
}
func socialPage(request *http.Request) (int32, int32, bool) {
page, ok := parsePositiveInt32Query(request, "page", 1)
if !ok {
return 0, 0, false
}
pageSize, ok := parsePositiveInt32Query(request, "page_size", 20)
if !ok {
return 0, 0, false
}
return page, pageSize, true
}
func parseUserSocialActionPath(path string) (int64, string, bool) {
if !strings.HasPrefix(path, apiV1Prefix+"/users/") {
return 0, "", false
}
raw := strings.Trim(strings.TrimPrefix(path, apiV1Prefix+"/users/"), "/")
parts := strings.Split(raw, "/")
if len(parts) < 2 || len(parts) > 3 || parts[0] == "me" {
return 0, "", false
}
userID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || userID <= 0 {
return 0, "", false
}
action := strings.Join(parts[1:], "/")
return userID, action, true
}
func profileVisitData(record *userv1.ProfileVisitRecord) profileVisitRecordData {
if record == nil {
return profileVisitRecordData{}
}
return profileVisitRecordData{
VisitorUserID: userIDString(record.GetVisitorUserId()),
TargetUserID: userIDString(record.GetTargetUserId()),
VisitCount: record.GetVisitCount(),
LastVisitedAtMS: record.GetLastVisitedAtMs(),
}
}
func followData(record *userv1.FollowRecord) followRecordData {
if record == nil {
return followRecordData{}
}
return followRecordData{
FollowerUserID: userIDString(record.GetFollowerUserId()),
FolloweeUserID: userIDString(record.GetFolloweeUserId()),
FollowedAtMS: record.GetFollowedAtMs(),
}
}
func friendData(record *userv1.FriendRecord) friendRecordData {
if record == nil {
return friendRecordData{}
}
return friendRecordData{
UserID: userIDString(record.GetUserId()),
FriendUserID: userIDString(record.GetFriendUserId()),
FriendedAtMS: record.GetFriendedAtMs(),
}
}
func friendApplicationDataFromProto(application *userv1.FriendApplication) friendApplicationData {
if application == nil {
return friendApplicationData{}
}
return friendApplicationData{
RequesterUserID: userIDString(application.GetRequesterUserId()),
TargetUserID: userIDString(application.GetTargetUserId()),
Status: application.GetStatus(),
CreatedAtMS: application.GetCreatedAtMs(),
UpdatedAtMS: application.GetUpdatedAtMs(),
}
}

View File

@ -0,0 +1,126 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
)
type fakeUserSocialClient struct {
lastVisit *userv1.RecordProfileVisitRequest
lastVisitors *userv1.ListProfileVisitorsRequest
lastFollow *userv1.FollowUserRequest
lastUnfollow *userv1.UnfollowUserRequest
lastFollowing *userv1.ListFollowingRequest
lastApply *userv1.ApplyFriendRequest
lastAccept *userv1.AcceptFriendApplicationRequest
lastDelete *userv1.DeleteFriendRequest
lastFriends *userv1.ListFriendsRequest
lastFriendApps *userv1.ListFriendApplicationsRequest
}
func (f *fakeUserSocialClient) RecordProfileVisit(_ context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
f.lastVisit = req
return &userv1.RecordProfileVisitResponse{Recorded: true, TargetStats: &userv1.UserProfileStats{UserId: req.GetTargetUserId(), VisitorsCount: 1}}, nil
}
func (f *fakeUserSocialClient) ListProfileVisitors(_ context.Context, req *userv1.ListProfileVisitorsRequest) (*userv1.ListProfileVisitorsResponse, error) {
f.lastVisitors = req
return &userv1.ListProfileVisitorsResponse{Records: []*userv1.ProfileVisitRecord{{VisitorUserId: 10002, TargetUserId: req.GetUserId(), VisitCount: 2, LastVisitedAtMs: 7000}}, Total: 1}, nil
}
func (f *fakeUserSocialClient) FollowUser(_ context.Context, req *userv1.FollowUserRequest) (*userv1.FollowUserResponse, error) {
f.lastFollow = req
return &userv1.FollowUserResponse{Following: true, FollowerStats: &userv1.UserProfileStats{UserId: req.GetFollowerUserId(), FollowingCount: 1}}, nil
}
func (f *fakeUserSocialClient) UnfollowUser(_ context.Context, req *userv1.UnfollowUserRequest) (*userv1.UnfollowUserResponse, error) {
f.lastUnfollow = req
return &userv1.UnfollowUserResponse{Following: false, FollowerStats: &userv1.UserProfileStats{UserId: req.GetFollowerUserId()}}, nil
}
func (f *fakeUserSocialClient) ListFollowing(_ context.Context, req *userv1.ListFollowingRequest) (*userv1.ListFollowingResponse, error) {
f.lastFollowing = req
return &userv1.ListFollowingResponse{Records: []*userv1.FollowRecord{{FollowerUserId: req.GetUserId(), FolloweeUserId: 10002, FollowedAtMs: 8000}}, Total: 1}, nil
}
func (f *fakeUserSocialClient) ApplyFriend(_ context.Context, req *userv1.ApplyFriendRequest) (*userv1.ApplyFriendResponse, error) {
f.lastApply = req
return &userv1.ApplyFriendResponse{Application: &userv1.FriendApplication{RequesterUserId: req.GetRequesterUserId(), TargetUserId: req.GetTargetUserId(), Status: "pending", CreatedAtMs: 9000, UpdatedAtMs: 9000}}, nil
}
func (f *fakeUserSocialClient) AcceptFriendApplication(_ context.Context, req *userv1.AcceptFriendApplicationRequest) (*userv1.AcceptFriendApplicationResponse, error) {
f.lastAccept = req
return &userv1.AcceptFriendApplicationResponse{Friend: &userv1.FriendRecord{UserId: req.GetAccepterUserId(), FriendUserId: req.GetRequesterUserId(), FriendedAtMs: 10000}}, nil
}
func (f *fakeUserSocialClient) DeleteFriend(_ context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error) {
f.lastDelete = req
return &userv1.DeleteFriendResponse{Deleted: true}, nil
}
func (f *fakeUserSocialClient) ListFriends(_ context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error) {
f.lastFriends = req
return &userv1.ListFriendsResponse{Records: []*userv1.FriendRecord{{UserId: req.GetUserId(), FriendUserId: 10002, FriendedAtMs: 10000}}, Total: 1}, nil
}
func (f *fakeUserSocialClient) ListFriendApplications(_ context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error) {
f.lastFriendApps = req
return &userv1.ListFriendApplicationsResponse{Applications: []*userv1.FriendApplication{{RequesterUserId: 10002, TargetUserId: req.GetUserId(), Status: "pending", CreatedAtMs: 9000, UpdatedAtMs: 9000}}, Total: 1}, nil
}
func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/users/10002/friend-requests", nil)
applyReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 10001))
applyResp := httptest.NewRecorder()
router.ServeHTTP(applyResp, applyReq)
if applyResp.Code != http.StatusOK {
t.Fatalf("apply friend status = %d body=%s", applyResp.Code, applyResp.Body.String())
}
if socialClient.lastApply == nil || socialClient.lastApply.GetRequesterUserId() != 10001 || socialClient.lastApply.GetTargetUserId() != 10002 {
t.Fatalf("apply friend request mismatch: %+v", socialClient.lastApply)
}
acceptReq := httptest.NewRequest(http.MethodPost, "/api/v1/users/10001/friend-requests/accept", nil)
acceptReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 10002))
acceptResp := httptest.NewRecorder()
router.ServeHTTP(acceptResp, acceptReq)
if acceptResp.Code != http.StatusOK {
t.Fatalf("accept friend status = %d body=%s", acceptResp.Code, acceptResp.Body.String())
}
if socialClient.lastAccept == nil || socialClient.lastAccept.GetAccepterUserId() != 10002 || socialClient.lastAccept.GetRequesterUserId() != 10001 {
t.Fatalf("accept friend request mismatch: %+v", socialClient.lastAccept)
}
}
func TestSocialListRoutesPassPagingAndDirection(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/friend-requests?direction=outgoing&page=2&page_size=30", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 10001))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("list friend applications status = %d body=%s", response.Code, response.Body.String())
}
if socialClient.lastFriendApps == nil || socialClient.lastFriendApps.GetUserId() != 10001 || socialClient.lastFriendApps.GetDirection() != "outgoing" || socialClient.lastFriendApps.GetPage() != 2 || socialClient.lastFriendApps.GetPageSize() != 30 {
t.Fatalf("friend application list request mismatch: %+v", socialClient.lastFriendApps)
}
var envelope responseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != codeOK {
t.Fatalf("invalid envelope: %+v err=%v", envelope, err)
}
}

View File

@ -0,0 +1,136 @@
package http
import (
"net/http"
"strings"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
// getMyVIP 返回当前用户 VIP 状态。
func (h *Handler) getMyVIP(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.GetMyVip(request.Context(), &walletv1.GetMyVipRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, vipFromProto(resp.GetVip()))
}
// listVIPPackages 返回可购买 VIP 包和当前会员状态。
func (h *Handler) listVIPPackages(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.walletClient.ListVipPackages(request.Context(), &walletv1.ListVipPackagesRequest{
RequestId: requestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
packages := make([]vipPackageData, 0, len(resp.GetPackages()))
for _, item := range resp.GetPackages() {
packages = append(packages, vipPackageFromProto(item))
}
writeOK(writer, request, map[string]any{"current_vip": vipFromProto(resp.GetCurrentVip()), "packages": packages})
}
// purchaseVIP 购买、续期或升级 VIP。
func (h *Handler) purchaseVIP(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body vipPurchaseRequestBody
if !decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
if commandID == "" || body.Level <= 0 {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.walletClient.PurchaseVip(request.Context(), &walletv1.PurchaseVipRequest{
CommandId: commandID,
AppCode: appcode.FromContext(request.Context()),
UserId: auth.UserIDFromContext(request.Context()),
Level: body.Level,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{
"order_id": resp.GetOrderId(),
"transaction_id": resp.GetTransactionId(),
"vip": vipFromProto(resp.GetVip()),
"coin_spent": resp.GetCoinSpent(),
"coin_balance_after": resp.GetCoinBalanceAfter(),
"reward_items": vipRewardItemsFromProto(resp.GetRewardItems()),
})
}
func vipFromProto(vip *walletv1.UserVip) overviewVIPData {
if vip == nil {
return overviewVIPData{}
}
return overviewVIPData{
Level: vip.GetLevel(),
Name: vip.GetName(),
Active: vip.GetActive(),
StartedAtMS: vip.GetStartedAtMs(),
ExpiresAtMS: vip.GetExpiresAtMs(),
}
}
func vipPackageFromProto(item *walletv1.VipLevel) vipPackageData {
if item == nil {
return vipPackageData{}
}
return vipPackageData{
Level: item.GetLevel(),
Name: item.GetName(),
Status: item.GetStatus(),
PriceCoin: item.GetPriceCoin(),
DurationMS: item.GetDurationMs(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
RewardItems: vipRewardItemsFromProto(item.GetRewardItems()),
CanPurchase: item.GetCanPurchase(),
SortOrder: item.GetSortOrder(),
}
}
func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemData {
resp := make([]vipRewardItemData, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
resp = append(resp, vipRewardItemData{
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
ResourceType: item.GetResourceType(),
Name: item.GetName(),
Quantity: item.GetQuantity(),
ExpiresAtMS: item.GetExpiresAtMs(),
})
}
return resp
}

View File

@ -109,3 +109,14 @@ CREATE TABLE IF NOT EXISTS room_outbox (
KEY idx_room_outbox_pending (app_code, status, 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 (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
feed_type VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, feed_type, room_id),
KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -16,7 +16,9 @@ import (
const (
roomListTabHot = "hot"
roomListTabNew = "new"
roomListTabMe = "me"
roomListTabVisited = "visited"
roomListTabFriend = "friend"
roomListTabFollowing = "following"
defaultRoomListLimit = 20
maxRoomListLimit = 50
maxRoomListQueryRunes = 64
@ -29,10 +31,11 @@ type roomListCursor struct {
Query string `json:"query,omitempty"`
SortScore int64 `json:"sort_score,omitempty"`
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
RoomID string `json:"room_id"`
}
// ListRooms 查询当前用户区域内的房间列表读模型。
// ListRooms 查询当前用户区域内的公共房间发现列表读模型。
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
viewerUserID := req.GetViewerUserId()
@ -55,16 +58,11 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
if err != nil {
return nil, err
}
ownerUserID := int64(0)
if tab == roomListTabMe {
ownerUserID = viewerUserID
}
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
AppCode: appcode.FromContext(ctx),
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Tab: tab,
OwnerUserID: ownerUserID,
Query: query,
Limit: limit + 1,
CursorSortScore: cursor.SortScore,
@ -75,6 +73,81 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
return nil, err
}
return roomListResponseFromEntries(tab, query, entries, limit), nil
}
// ListRoomFeeds 查询 Mine 页 visited/friend/following 用户房间流。
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
viewerUserID := req.GetViewerUserId()
if viewerUserID <= 0 {
// feed 一定绑定当前登录用户,不能允许匿名或客户端自选用户。
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
tab := normalizeRoomFeedTab(req.GetTab())
if tab == "" {
return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
}
limit := normalizeRoomListLimit(req.GetLimit())
query, err := normalizeRoomListQuery(req.GetQuery())
if err != nil {
return nil, err
}
cursor, err := decodeRoomListCursor(tab, query, req.GetCursor())
if err != nil {
return nil, err
}
entries, err := s.repository.ListRoomUserFeedEntries(ctx, RoomUserFeedQuery{
AppCode: appcode.FromContext(ctx),
UserID: viewerUserID,
FeedType: tab,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Query: query,
Limit: limit + 1,
CursorUpdatedAtMS: cursor.UpdatedAtMS,
CursorRoomID: cursor.RoomID,
})
if err != nil {
return nil, err
}
return roomListResponseFromEntries(tab, query, entries, limit), nil
}
// GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。
func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
ownerUserID := req.GetOwnerUserId()
if ownerUserID <= 0 {
// owner_user_id 必须由 gateway 鉴权后填充Mine 卡片不能接受客户端任意查人。
return nil, xerr.New(xerr.InvalidArgument, "owner_user_id is required")
}
serverTimeMS := s.clock.Now().UnixMilli()
meta, exists, err := s.repository.GetRoomMetaByOwner(ctx, ownerUserID)
if err != nil {
return nil, err
}
if !exists {
return &roomv1.GetMyRoomResponse{ServerTimeMs: serverTimeMS}, nil
}
snapshot, err := s.currentSnapshot(ctx, meta.RoomID)
if err != nil {
return nil, err
}
return &roomv1.GetMyRoomResponse{
HasRoom: true,
Room: roomListItemToProto(roomListEntryFromMetaAndSnapshot(meta, snapshot, serverTimeMS)),
ServerTimeMs: serverTimeMS,
}, nil
}
func roomListResponseFromEntries(tab string, query string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse {
nextCursor := ""
if len(entries) > limit {
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
@ -91,7 +164,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (
return &roomv1.ListRoomsResponse{
Rooms: items,
NextCursor: nextCursor,
}, nil
}
}
// projectRoomListBestEffort 根据最新快照刷新房间列表投影。
@ -164,15 +237,26 @@ func roomListSortScore(heat int64, onlineCount int32, occupiedSeatCount int32) i
return heat*1000 + int64(onlineCount)*100 + int64(occupiedSeatCount)*10
}
// normalizeRoomListTab 固定首版只支持 hot/new/me未知 tab fail-closed
// normalizeRoomListTab 只支持公共发现列表 tabMine 关系流走 ListRoomFeeds
func normalizeRoomListTab(tab string) string {
switch strings.ToLower(strings.TrimSpace(tab)) {
case "", roomListTabHot:
return roomListTabHot
case roomListTabNew:
return roomListTabNew
case roomListTabMe:
return roomListTabMe
default:
return ""
}
}
func normalizeRoomFeedTab(tab string) string {
switch strings.ToLower(strings.TrimSpace(tab)) {
case roomListTabVisited:
return roomListTabVisited
case roomListTabFriend:
return roomListTabFriend
case roomListTabFollowing:
return roomListTabFollowing
default:
return ""
}
@ -240,6 +324,7 @@ func encodeRoomListCursor(tab string, query string, entry RoomListEntry) string
Query: query,
SortScore: entry.SortScore,
CreatedAtMS: entry.CreatedAtMS,
UpdatedAtMS: entry.UpdatedAtMS,
RoomID: entry.RoomID,
}
@ -271,3 +356,39 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
VisibleRegionId: entry.VisibleRegionID,
}
}
func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
if snapshot == nil || snapshot.GetRoomId() == "" {
return RoomListEntry{
AppCode: meta.AppCode,
RoomID: meta.RoomID,
RoomShortID: meta.RoomShortID,
VisibleRegionID: normalizeVisibleRegionID(meta.VisibleRegionID),
OwnerUserID: meta.OwnerUserID,
HostUserID: meta.HostUserID,
Mode: meta.Mode,
Status: meta.Status,
SeatCount: meta.SeatCount,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
}
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if entry.RoomShortID == "" {
entry.RoomShortID = meta.RoomShortID
}
if entry.Mode == "" {
entry.Mode = meta.Mode
}
if entry.Status == "" {
entry.Status = meta.Status
}
if entry.OwnerUserID <= 0 {
entry.OwnerUserID = meta.OwnerUserID
}
if entry.HostUserID <= 0 {
entry.HostUserID = meta.HostUserID
}
return entry
}

View File

@ -2,12 +2,14 @@ package service
import (
"context"
"log/slog"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
@ -86,6 +88,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
if err != nil {
return nil, err
}
s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())
return &roomv1.JoinRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
@ -94,6 +97,29 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
}, nil
}
// projectRoomVisitBestEffort 记录 Mine/Visited tab 的房间访问索引。
// 这是列表读模型,不参与 Room Cell 提交;失败只记录日志,不能影响已经成功的 JoinRoom。
func (s *Service) projectRoomVisitBestEffort(ctx context.Context, userID int64, roomID string) {
if userID <= 0 || roomID == "" {
return
}
nowMS := s.clock.Now().UnixMilli()
if err := s.repository.UpsertRoomUserFeedEntry(ctx, RoomUserFeedEntry{
AppCode: appcode.FromContext(ctx),
UserID: userID,
FeedType: roomListTabVisited,
RoomID: roomID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}); err != nil {
logx.Error(ctx, "room_visit_feed_upsert_failed", err,
slog.String("component", "room_user_feed_projector"),
slog.String("room_id", roomID),
slog.Int64("user_id", userID),
)
}
}
// RoomHeartbeat 显式刷新已在房间内用户的业务 presence。
func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())

View File

@ -146,9 +146,9 @@ type RoomListQuery struct {
AppCode string
// VisibleRegionID 是服务端解析出的用户区域,客户端不能伪造。
VisibleRegionID int64
// Tab 只允许 hot/new/me决定过滤和排序字段。
// Tab 只允许 hot/new,决定公共发现列表排序字段。
Tab string
// OwnerUserID 非 0 时只返回该用户创建的房间
// OwnerUserID 保留给内部定向读模型;公开 Mine 顶部卡片走 GetMyRoom
OwnerUserID int64
// Query 非空时按 room_id 或 title 做包含匹配。
Query string
@ -158,10 +158,49 @@ type RoomListQuery struct {
CursorSortScore int64
// CursorCreatedAtMS 是 new tab 的游标创建时间。
CursorCreatedAtMS int64
// CursorUpdatedAtMS 是用户关系 feed tab 的游标更新时间。
CursorUpdatedAtMS int64
// CursorRoomID 是同分或同时间下的稳定翻页边界。
CursorRoomID string
}
// RoomUserFeedEntry 是“访问过/好友/关注”房间流的读模型索引。
// 它只保存用户到房间的列表关系,不复制房间卡片字段,卡片内容仍来自 room_list_entries。
type RoomUserFeedEntry struct {
// AppCode 是 feed 所属 App防止跨 App 看到关系流。
AppCode string
// UserID 是拥有该 feed 的用户。
UserID int64
// FeedType 是 visited/friend/following 之一;关系事实 owner 后续可通过事件填充 friend/following。
FeedType string
// RoomID 是 feed 指向的房间。
RoomID string
// CreatedAtMS 是首次建立该 feed 关系的 UTC 毫秒。
CreatedAtMS int64
// UpdatedAtMS 是 feed 排序时间visited 会在 JoinRoom 成功后刷新。
UpdatedAtMS int64
}
// RoomUserFeedQuery 是用户房间关系流的分页查询条件。
type RoomUserFeedQuery struct {
// AppCode 是 feed 查询所属 App。
AppCode string
// UserID 是当前登录用户。
UserID int64
// FeedType 是 visited/friend/following 之一。
FeedType string
// VisibleRegionID 沿用房间发现页区域桶,避免关系流跨区域露出。
VisibleRegionID int64
// Query 非空时按 room_id、room_short_id 或 title 做包含匹配。
Query string
// Limit 是实际查询数量service 层会限制最大值。
Limit int
// CursorUpdatedAtMS 是上一页最后一条 feed 更新时间。
CursorUpdatedAtMS int64
// CursorRoomID 是同时间下的稳定翻页边界。
CursorRoomID string
}
// Repository 聚合 room-service 在首版需要的全部持久化读写。
type Repository interface {
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
@ -196,6 +235,10 @@ type Repository interface {
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
// ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。
ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error)
// UpsertRoomUserFeedEntry 写入用户房间关系流读模型;失败不应影响房间主链路。
UpsertRoomUserFeedEntry(ctx context.Context, entry RoomUserFeedEntry) error
// ListRoomUserFeedEntries 按用户关系流读取房间卡片,不访问 Room Cell 内存集合。
ListRoomUserFeedEntries(ctx context.Context, query RoomUserFeedQuery) ([]RoomListEntry, error)
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
// GetCurrentRoomPresence 查询用户当前 active 房间 presence不扫描房间列表或快照。

View File

@ -333,6 +333,106 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
}
}
func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-my-room-owner"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 42),
SeatCount: 6,
Mode: "voice",
RoomName: "Owner Top Card",
RoomAvatar: "https://cdn.example.com/owner-room.png",
VisibleRegionId: 1001,
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
repository.DeleteRoomListEntry(roomID)
resp, err := svc.GetMyRoom(ctx, &roomv1.GetMyRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
OwnerUserId: 42,
})
if err != nil {
t.Fatalf("GetMyRoom failed: %v", err)
}
if !resp.GetHasRoom() || resp.GetRoom().GetRoomId() != roomID {
t.Fatalf("my room should return owner room from rooms metadata: %+v", resp)
}
if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 6 || resp.GetRoom().GetVisibleRegionId() != 1001 {
t.Fatalf("my room card should use snapshot/meta fields: %+v", resp.GetRoom())
}
}
func TestListRoomFeedsVisitedUsesJoinRoomFeed(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-visited-feed"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Visited Room",
VisibleRegionId: 1001,
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("JoinRoom failed: %v", err)
}
resp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
ViewerUserId: 2,
VisibleRegionId: 1001,
Tab: "visited",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRoomFeeds visited failed: %v", err)
}
if len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetRoomId() != roomID {
t.Fatalf("visited feed should expose joined room: %+v", resp.GetRooms())
}
otherUserResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
ViewerUserId: 3,
VisibleRegionId: 1001,
Tab: "visited",
Limit: 20,
})
if err != nil {
t.Fatalf("ListRoomFeeds visited for other user failed: %v", err)
}
if len(otherUserResp.GetRooms()) != 0 {
t.Fatalf("visited feed must be scoped to viewer: %+v", otherUserResp.GetRooms())
}
}
func TestListRoomsRejectsUserFeedTabs(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
for _, tab := range []string{"visited", "friend", "following", "me"} {
_, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
ViewerUserId: 2,
VisibleRegionId: 1001,
Tab: tab,
Limit: 20,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("ListRooms must reject %q tab: %v", tab, err)
}
}
}
func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -167,12 +167,23 @@ func (r *Repository) Migrate(ctx context.Context) error {
KEY idx_room_outbox_pending (app_code, status, 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 (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
feed_type VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, feed_type, room_id),
KEY idx_room_user_feed_list (app_code, user_id, feed_type, updated_at_ms DESC, room_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_feed_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
}
for _, statement := range statements {
@ -812,11 +823,84 @@ func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.
return entries, nil
}
// UpsertRoomUserFeedEntry 写入用户房间关系流索引。
func (r *Repository) UpsertRoomUserFeedEntry(ctx context.Context, entry roomservice.RoomUserFeedEntry) error {
// feed 只保存用户到房间的索引;房间卡片字段仍以 room_list_entries 为准,避免复制不一致。
appCode := normalizedRecordAppCode(ctx, entry.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_user_feed_entries (app_code, user_id, feed_type, room_id, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
appCode,
entry.UserID,
entry.FeedType,
entry.RoomID,
entry.CreatedAtMS,
entry.UpdatedAtMS,
)
return err
}
// ListRoomUserFeedEntries 按用户关系流读取房间卡片。
func (r *Repository) ListRoomUserFeedEntries(ctx context.Context, query roomservice.RoomUserFeedQuery) ([]roomservice.RoomListEntry, error) {
if query.Limit <= 0 {
query.Limit = 20
}
query.AppCode = normalizedRecordAppCode(ctx, query.AppCode)
sqlText, args := buildRoomUserFeedQuerySQL(query)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
for rows.Next() {
var entry roomservice.RoomListEntry
if err := rows.Scan(
&entry.AppCode,
&entry.RoomID,
&entry.RoomShortID,
&entry.VisibleRegionID,
&entry.OwnerUserID,
&entry.HostUserID,
&entry.Title,
&entry.CoverURL,
&entry.Mode,
&entry.Status,
&entry.Heat,
&entry.OnlineCount,
&entry.SeatCount,
&entry.OccupiedSeatCount,
&entry.SortScore,
&entry.CreatedAtMS,
&entry.UpdatedAtMS,
); err != nil {
return nil, err
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, err
}
return entries, nil
}
const roomListSelectColumns = `
SELECT app_code, room_id, room_short_id, visible_region_id, owner_user_id, host_user_id, title, cover_url, mode, status,
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
FROM room_list_entries`
const roomUserFeedSelectColumns = `
SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_user_id, r.host_user_id, r.title, r.cover_url, r.mode, r.status,
r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, f.updated_at_ms
FROM room_user_feed_entries f
JOIN room_list_entries r ON r.app_code = f.app_code AND r.room_id = f.room_id`
func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
where := []string{"app_code = ?", "visible_region_id = ?", "status = ?"}
args := []any{appcode.Normalize(query.AppCode), query.VisibleRegionID, "active"}
@ -829,7 +913,7 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
if query.Tab == "new" || query.Tab == "me" {
if query.Tab == "new" {
if query.CursorRoomID != "" {
where = append(where, "(created_at_ms < ? OR (created_at_ms = ? AND room_id > ?))")
args = append(args, query.CursorCreatedAtMS, query.CursorCreatedAtMS, query.CursorRoomID)
@ -846,6 +930,22 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) {
return roomListSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY sort_score DESC, room_id ASC\n\tLIMIT ?", args
}
func buildRoomUserFeedQuerySQL(query roomservice.RoomUserFeedQuery) (string, []any) {
where := []string{"f.app_code = ?", "f.user_id = ?", "f.feed_type = ?", "r.visible_region_id = ?", "r.status = ?"}
args := []any{appcode.Normalize(query.AppCode), query.UserID, query.FeedType, query.VisibleRegionID, "active"}
if strings.TrimSpace(query.Query) != "" {
where = append(where, "(r.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\')")
like := "%" + escapeRoomListLike(query.Query) + "%"
args = append(args, like, like, like)
}
if query.CursorRoomID != "" {
where = append(where, "(f.updated_at_ms < ? OR (f.updated_at_ms = ? AND f.room_id > ?))")
args = append(args, query.CursorUpdatedAtMS, query.CursorUpdatedAtMS, query.CursorRoomID)
}
args = append(args, query.Limit)
return roomUserFeedSelectColumns + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY f.updated_at_ms DESC, f.room_id ASC\n\tLIMIT ?", args
}
// ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。
func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error {
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)

View File

@ -63,6 +63,15 @@ func (r *Repository) SetRoomStatus(roomID string, status string) {
}
}
// DeleteRoomListEntry removes the discovery projection while keeping rooms metadata.
func (r *Repository) DeleteRoomListEntry(roomID string) {
r.t.Helper()
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, appcode.Default, roomID); err != nil {
r.t.Fatalf("delete room list entry failed: %v", err)
}
}
// OutboxRecord reads one outbox row, including delivered rows that normal scans skip.
func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
r.t.Helper()

View File

@ -187,6 +187,20 @@ func (s *Server) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*
return mapServiceResult(s.svc.ListRooms(ctx, req))
}
// ListRoomFeeds 代理到 Mine 页用户房间流读模型。
func (s *Server) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
// feed 查询只读用户到房间索引,并 join 房间卡片投影,不扫描 Room Cell 内存集合。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.ListRoomFeeds(ctx, req))
}
// GetMyRoom 代理到 Mine 顶部“我的房间”权威查询。
func (s *Server) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
// 我的房间查询直接读取 rooms 元数据,并用快照补齐实时卡片字段,不依赖发现页投影。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.GetMyRoom(ctx, req))
}
// GetCurrentRoom 代理到用户当前房间恢复探测读模型。
func (s *Server) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
// 恢复探测不修改 Room Cell只在读模型命中后用快照做二次校验。

View File

@ -68,6 +68,65 @@ CREATE TABLE IF NOT EXISTS users (
KEY idx_users_country_region (app_code, country, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_profile_stats (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
visitors_count BIGINT NOT NULL DEFAULT 0,
following_count BIGINT NOT NULL DEFAULT 0,
friends_count BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_profile_visits (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
target_user_id BIGINT NOT NULL,
visitor_user_id BIGINT NOT NULL,
visit_count BIGINT NOT NULL DEFAULT 1,
first_visited_at_ms BIGINT NOT NULL,
last_visited_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, target_user_id, visitor_user_id),
KEY idx_user_profile_visits_visitor (app_code, visitor_user_id, last_visited_at_ms),
KEY idx_user_profile_visits_target_time (app_code, target_user_id, last_visited_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_follows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
follower_user_id BIGINT NOT NULL,
followee_user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
followed_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, follower_user_id, followee_user_id),
KEY idx_user_follows_followee_status (app_code, followee_user_id, status, updated_at_ms),
KEY idx_user_follows_follower_status (app_code, follower_user_id, status, followed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_friend_applications (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
requester_user_id BIGINT NOT NULL,
target_user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, requester_user_id, target_user_id),
KEY idx_friend_applications_target_status (app_code, target_user_id, status, updated_at_ms),
KEY idx_friend_applications_requester_status (app_code, requester_user_id, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_friendships (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
friend_user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
friended_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id, friend_user_id),
KEY idx_user_friendships_user_status (app_code, user_id, status, friended_at_ms),
KEY idx_user_friendships_friend_status (app_code, friend_user_id, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS display_user_id_sequences (
app_code VARCHAR(32) NOT NULL PRIMARY KEY,
next_value BIGINT NOT NULL,

View File

@ -215,6 +215,7 @@ func New(cfg config.Config) (*App, error) {
// 多个 protobuf service 共用一个 Server 适配器,领域逻辑仍拆在 auth/user/host service。
userv1.RegisterAuthServiceServer(server, userServer)
userv1.RegisterUserServiceServer(server, userServer)
userv1.RegisterUserSocialServiceServer(server, userServer)
userv1.RegisterUserDeviceServiceServer(server, userServer)
userv1.RegisterAppRegistryServiceServer(server, userServer)
userv1.RegisterUserIdentityServiceServer(server, userServer)

View File

@ -178,6 +178,23 @@ type CoinSellerProfile struct {
UpdatedAtMs int64
}
// UserRoleSummary 是面向 App 入口显隐的聚合 read model。
// 这里不承载团队统计或薪资指标,只表达当前用户是否拥有可用身份和待处理邀请数量。
type UserRoleSummary struct {
UserID int64
IsHost bool
IsAgency bool
IsBD bool
IsBDLeader bool
IsCoinSeller bool
HostStatus string
AgencyID int64
BDID int64
BDStatus string
CoinSellerStatus string
PendingRoleInvitations int64
}
// AgencyMembership 是 host 与 Agency 的有时效归属事实。
type AgencyMembership struct {
MembershipID int64

View File

@ -165,6 +165,66 @@ type User struct {
UpdatedAtMs int64
}
// ProfileStats 是我的页顶部统计 read model只保存预聚合计数。
type ProfileStats struct {
// AppCode 是统计所属 App关系链和礼物统计都必须在 App 内隔离。
AppCode string
// UserID 是统计所属用户。
UserID int64
// VisitorsCount 是访问过该用户主页的用户计数投影。
VisitorsCount int64
// FollowingCount 是该用户关注的人数计数投影。
FollowingCount int64
// FriendsCount 是该用户好友人数计数投影。
FriendsCount int64
// UpdatedAtMs 是这份计数投影最后更新时间。
UpdatedAtMs int64
}
const (
// SocialStatusActive 表示关注或好友关系当前有效。
SocialStatusActive = "active"
// SocialStatusCancelled 表示关注关系已取消。
SocialStatusCancelled = "cancelled"
// SocialStatusDeleted 表示好友关系已删除。
SocialStatusDeleted = "deleted"
// FriendApplicationStatusPending 表示好友申请等待对方处理。
FriendApplicationStatusPending = "pending"
// FriendApplicationStatusAccepted 表示好友申请已同意。
FriendApplicationStatusAccepted = "accepted"
)
// ProfileVisitRecord 是访问某个用户主页的去重记录。
type ProfileVisitRecord struct {
VisitorUserID int64
TargetUserID int64
VisitCount int64
LastVisitedAtMs int64
}
// FollowRecord 是单向关注关系。
type FollowRecord struct {
FollowerUserID int64
FolloweeUserID int64
FollowedAtMs int64
}
// FriendRecord 是双向好友关系在单用户视角下的一条投影。
type FriendRecord struct {
UserID int64
FriendUserID int64
FriendedAtMs int64
}
// FriendApplication 是好友申请事实。
type FriendApplication struct {
RequesterUserID int64
TargetUserID int64
Status string
CreatedAtMs int64
UpdatedAtMs int64
}
// Identity 是 user_id 与当前 active display_user_id 的绑定投影。
type Identity struct {
// AppCode 是短号所属 App。

View File

@ -32,6 +32,7 @@ type Repository interface {
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error)
GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error)
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error)
}
@ -482,6 +483,18 @@ func (s *Service) GetCoinSellerProfile(ctx context.Context, userID int64) (hostd
return s.repository.GetCoinSellerProfile(ctx, userID)
}
// GetUserRoleSummary 读取 App 我的页使用的轻量身份摘要,避免 gateway 为入口显隐发起多个角色 RPC。
func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
if s.repository == nil {
return hostdomain.UserRoleSummary{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if userID <= 0 {
return hostdomain.UserRoleSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
return s.repository.GetUserRoleSummary(ctx, userID)
}
// ListAgencyMembers 读取 Agency 成员关系列表,第一阶段直接读事实表。
func (s *Service) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
if s.repository == nil {

View File

@ -495,3 +495,62 @@ func TestAdminCreateCoinSellerCanCoexistWithBDAndAgency(t *testing.T) {
t.Fatalf("disabled coin seller mismatch: %+v", disabled)
}
}
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 931, 60)
seedActiveUser(t, repository, 932, 60)
repository.PutHostProfile(hostdomain.HostProfile{
UserID: 931,
Status: hostdomain.HostStatusActive,
RegionID: 60,
CurrentAgencyID: 933,
Source: hostdomain.HostSourceAdminCreateAgency,
})
repository.PutAgency(hostdomain.Agency{
AgencyID: 933,
OwnerUserID: 931,
RegionID: 60,
ParentBDUserID: 931,
Status: hostdomain.AgencyStatusActive,
JoinEnabled: true,
})
repository.PutBDProfile(hostdomain.BDProfile{
UserID: 931,
Role: hostdomain.BDRoleLeader,
RegionID: 60,
Status: hostdomain.BDStatusActive,
})
svc := newHostService(repository, 8000, 9000)
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
CommandID: "admin-create-coin-seller-931",
AdminUserID: 1,
TargetUserID: 931,
Reason: "summary test",
}); err != nil {
t.Fatalf("CreateCoinSeller failed: %v", err)
}
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
CommandID: "invite-bd-932",
InviterUserID: 931,
TargetUserID: 932,
}); err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
summary, err := svc.GetUserRoleSummary(ctx, 931)
if err != nil {
t.Fatalf("GetUserRoleSummary failed: %v", err)
}
if !summary.IsHost || !summary.IsAgency || !summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
t.Fatalf("role summary mismatch: %+v", summary)
}
pending, err := svc.GetUserRoleSummary(ctx, 932)
if err != nil {
t.Fatalf("GetUserRoleSummary pending failed: %v", err)
}
if pending.PendingRoleInvitations != 1 || pending.IsHost || pending.IsBD {
t.Fatalf("pending role summary mismatch: %+v", pending)
}
}

View File

@ -14,6 +14,28 @@ import (
type UserRepository interface {
// GetUser 查询单个用户主状态。
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
// GetMyProfileStats 读取我的页顶部统计 read model。
GetMyProfileStats(ctx context.Context, userID int64) (userdomain.ProfileStats, error)
// RecordProfileVisit 记录一次用户主页访问,并更新被访问者计数。
RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, nowMs int64) (bool, userdomain.ProfileStats, error)
// ListProfileVisitors 分页读取访问当前用户主页的人。
ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error)
// FollowUser 创建或恢复单向关注,并更新关注计数。
FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error)
// UnfollowUser 取消单向关注,并更新关注计数。
UnfollowUser(ctx context.Context, followerUserID int64, followeeUserID int64, nowMs int64) (userdomain.ProfileStats, error)
// ListFollowing 分页读取当前用户关注的人。
ListFollowing(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.FollowRecord, int64, error)
// ApplyFriend 创建好友申请;已有好友时不重复建申请。
ApplyFriend(ctx context.Context, requesterUserID int64, targetUserID int64, nowMs int64) (userdomain.FriendApplication, bool, error)
// AcceptFriendApplication 同意指定 requester 发来的好友申请,并写双向好友关系。
AcceptFriendApplication(ctx context.Context, accepterUserID int64, requesterUserID int64, nowMs int64) (userdomain.FriendRecord, error)
// DeleteFriend 删除双向好友关系。
DeleteFriend(ctx context.Context, userID int64, friendUserID int64, nowMs int64) (bool, error)
// ListFriends 分页读取好友列表。
ListFriends(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.FriendRecord, int64, error)
// ListFriendApplications 分页读取好友申请。
ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error)
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。

View File

@ -137,6 +137,134 @@ func TestGetUserUsesRepository(t *testing.T) {
}
}
func TestGetMyProfileStatsReadsCounterTable(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)
stats, err := svc.GetMyProfileStats(context.Background(), 10001)
if err != nil {
t.Fatalf("GetMyProfileStats default failed: %v", err)
}
if stats.UserID != 10001 || stats.VisitorsCount != 0 || stats.FollowingCount != 0 || stats.FriendsCount != 0 {
t.Fatalf("missing stats should return zero projection: %+v", stats)
}
_, err = repository.RawDB().Exec(`
INSERT INTO user_profile_stats (
app_code, user_id, visitors_count, following_count, friends_count, updated_at_ms
) VALUES ('lalu', 10001, 12, 34, 56, 7000)`)
if err != nil {
t.Fatalf("seed profile stats failed: %v", err)
}
stats, err = svc.GetMyProfileStats(context.Background(), 10001)
if err != nil {
t.Fatalf("GetMyProfileStats failed: %v", err)
}
if stats.VisitorsCount != 12 || stats.FollowingCount != 34 || stats.FriendsCount != 56 || stats.UpdatedAtMs != 7000 {
t.Fatalf("stats mismatch: %+v", stats)
}
}
func TestSocialRelationsUseReadModelsAndIdempotentEdges(t *testing.T) {
repository := mysqltest.NewRepository(t)
for _, seeded := range []userdomain.User{
{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive},
{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive},
{UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive},
} {
repository.PutUser(completedUser(seeded))
}
now := time.UnixMilli(9000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
if _, _, err := svc.RecordProfileVisit(context.Background(), 10001, 10001); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("self visit should be rejected, got %v", err)
}
recorded, stats, err := svc.RecordProfileVisit(context.Background(), 10001, 10002)
if err != nil || !recorded || stats.VisitorsCount != 1 {
t.Fatalf("first visit mismatch: recorded=%v stats=%+v err=%v", recorded, stats, err)
}
recorded, stats, err = svc.RecordProfileVisit(context.Background(), 10001, 10002)
if err != nil || recorded || stats.VisitorsCount != 1 {
t.Fatalf("repeat visit should not increase unique visitors: recorded=%v stats=%+v err=%v", recorded, stats, err)
}
visitors, total, err := svc.ListProfileVisitors(context.Background(), 10002, 1, 20)
if err != nil || total != 1 || len(visitors) != 1 || visitors[0].VisitCount != 2 || visitors[0].VisitorUserID != 10001 {
t.Fatalf("visitors mismatch: visitors=%+v total=%d err=%v", visitors, total, err)
}
stats, err = svc.FollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("follow failed: stats=%+v err=%v", stats, err)
}
stats, err = svc.FollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 1 {
t.Fatalf("repeat follow should be idempotent: stats=%+v err=%v", stats, err)
}
following, total, err := svc.ListFollowing(context.Background(), 10001, 1, 20)
if err != nil || total != 1 || len(following) != 1 || following[0].FolloweeUserID != 10002 {
t.Fatalf("following mismatch: following=%+v total=%d err=%v", following, total, err)
}
stats, err = svc.UnfollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 0 {
t.Fatalf("unfollow failed: stats=%+v err=%v", stats, err)
}
stats, err = svc.UnfollowUser(context.Background(), 10001, 10002)
if err != nil || stats.FollowingCount != 0 {
t.Fatalf("repeat unfollow should stay zero: stats=%+v err=%v", stats, err)
}
application, alreadyFriends, err := svc.ApplyFriend(context.Background(), 10001, 10002)
if err != nil || alreadyFriends || application.Status != userdomain.FriendApplicationStatusPending {
t.Fatalf("apply friend mismatch: app=%+v already=%v err=%v", application, alreadyFriends, err)
}
friends, total, err := svc.ListFriends(context.Background(), 10001, 1, 20)
if err != nil || total != 0 || len(friends) != 0 {
t.Fatalf("friend application must not create friendship before accept: friends=%+v total=%d err=%v", friends, total, err)
}
applications, total, err := svc.ListFriendApplications(context.Background(), 10002, "incoming", 1, 20)
if err != nil || total != 1 || len(applications) != 1 || applications[0].RequesterUserID != 10001 {
t.Fatalf("incoming application mismatch: applications=%+v total=%d err=%v", applications, total, err)
}
friend, err := svc.AcceptFriendApplication(context.Background(), 10002, 10001)
if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 {
t.Fatalf("accept friend failed: friend=%+v err=%v", friend, err)
}
friend, err = svc.AcceptFriendApplication(context.Background(), 10002, 10001)
if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 {
t.Fatalf("repeat accept should be idempotent: friend=%+v err=%v", friend, err)
}
for _, userID := range []int64{10001, 10002} {
friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20)
if err != nil || total != 1 || len(friends) != 1 {
t.Fatalf("bidirectional friendship missing for user %d: friends=%+v total=%d err=%v", userID, friends, total, err)
}
stats, err = svc.GetMyProfileStats(context.Background(), userID)
if err != nil || stats.FriendsCount != 1 {
t.Fatalf("friend count mismatch for user %d: stats=%+v err=%v", userID, stats, err)
}
}
deleted, err := svc.DeleteFriend(context.Background(), 10001, 10002)
if err != nil || !deleted {
t.Fatalf("delete friend failed: deleted=%v err=%v", deleted, err)
}
deleted, err = svc.DeleteFriend(context.Background(), 10001, 10002)
if err != nil || deleted {
t.Fatalf("repeat delete should be idempotent false: deleted=%v err=%v", deleted, err)
}
for _, userID := range []int64{10001, 10002} {
friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20)
if err != nil || total != 0 || len(friends) != 0 {
t.Fatalf("friend should be removed for user %d: friends=%+v total=%d err=%v", userID, friends, total, err)
}
stats, err = svc.GetMyProfileStats(context.Background(), userID)
if err != nil || stats.FriendsCount != 0 {
t.Fatalf("friend count should be decremented for user %d: stats=%+v err=%v", userID, stats, err)
}
}
}
func TestListUserIDsUsesCursorAndTargetFilters(t *testing.T) {
repository := mysqltest.NewRepository(t)
for _, user := range []userdomain.User{

View File

@ -0,0 +1,157 @@
package user
import (
"context"
"strings"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
defaultSocialPageSize = 20
maxSocialPageSize = 100
)
// RecordProfileVisit 记录访问关系;自己访问自己不进入访问计数。
func (s *Service) RecordProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64) (bool, userdomain.ProfileStats, error) {
if visitorUserID <= 0 || targetUserID <= 0 {
return false, userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if visitorUserID == targetUserID {
return false, userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot visit self")
}
if s.userRepository == nil {
return false, userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, s.now().UnixMilli())
}
func (s *Service) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
}
page, pageSize = normalizeSocialPage(page, pageSize)
return s.userRepository.ListProfileVisitors(ctx, userID, page, pageSize)
}
func (s *Service) FollowUser(ctx context.Context, followerUserID int64, followeeUserID int64) (userdomain.ProfileStats, error) {
if followerUserID <= 0 || followeeUserID <= 0 {
return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if followerUserID == followeeUserID {
return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot follow self")
}
if s.userRepository == nil {
return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.FollowUser(ctx, followerUserID, followeeUserID, s.now().UnixMilli())
}
func (s *Service) UnfollowUser(ctx context.Context, followerUserID int64, followeeUserID int64) (userdomain.ProfileStats, error) {
if followerUserID <= 0 || followeeUserID <= 0 {
return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if followerUserID == followeeUserID {
return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "cannot unfollow self")
}
if s.userRepository == nil {
return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.UnfollowUser(ctx, followerUserID, followeeUserID, s.now().UnixMilli())
}
func (s *Service) ListFollowing(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.FollowRecord, int64, error) {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
}
page, pageSize = normalizeSocialPage(page, pageSize)
return s.userRepository.ListFollowing(ctx, userID, page, pageSize)
}
func (s *Service) ApplyFriend(ctx context.Context, requesterUserID int64, targetUserID int64) (userdomain.FriendApplication, bool, error) {
if requesterUserID <= 0 || targetUserID <= 0 {
return userdomain.FriendApplication{}, false, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if requesterUserID == targetUserID {
return userdomain.FriendApplication{}, false, xerr.New(xerr.InvalidArgument, "cannot add self as friend")
}
if s.userRepository == nil {
return userdomain.FriendApplication{}, false, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.ApplyFriend(ctx, requesterUserID, targetUserID, s.now().UnixMilli())
}
func (s *Service) AcceptFriendApplication(ctx context.Context, accepterUserID int64, requesterUserID int64) (userdomain.FriendRecord, error) {
if accepterUserID <= 0 || requesterUserID <= 0 {
return userdomain.FriendRecord{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if accepterUserID == requesterUserID {
return userdomain.FriendRecord{}, xerr.New(xerr.InvalidArgument, "cannot accept self friend request")
}
if s.userRepository == nil {
return userdomain.FriendRecord{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.AcceptFriendApplication(ctx, accepterUserID, requesterUserID, s.now().UnixMilli())
}
func (s *Service) DeleteFriend(ctx context.Context, userID int64, friendUserID int64) (bool, error) {
if userID <= 0 || friendUserID <= 0 {
return false, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if userID == friendUserID {
return false, xerr.New(xerr.InvalidArgument, "cannot delete self friend")
}
if s.userRepository == nil {
return false, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.DeleteFriend(ctx, userID, friendUserID, s.now().UnixMilli())
}
func (s *Service) ListFriends(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.FriendRecord, int64, error) {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
}
page, pageSize = normalizeSocialPage(page, pageSize)
return s.userRepository.ListFriends(ctx, userID, page, pageSize)
}
func (s *Service) ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error) {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
direction = strings.ToLower(strings.TrimSpace(direction))
if direction == "" {
direction = "incoming"
}
if direction != "incoming" && direction != "outgoing" {
return nil, 0, xerr.New(xerr.InvalidArgument, "direction is invalid")
}
if s.userRepository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
}
page, pageSize = normalizeSocialPage(page, pageSize)
return s.userRepository.ListFriendApplications(ctx, userID, direction, page, pageSize)
}
func normalizeSocialPage(page int32, pageSize int32) (int32, int32) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = defaultSocialPageSize
}
if pageSize > maxSocialPageSize {
pageSize = maxSocialPageSize
}
return page, pageSize
}

View File

@ -35,6 +35,17 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (userdomain.User, e
return s.refreshExpiredUser(ctx, user, "")
}
// GetMyProfileStats 读取我的页顶部统计计数;统计事实必须由专门 read model 维护。
func (s *Service) GetMyProfileStats(ctx context.Context, userID int64) (userdomain.ProfileStats, error) {
if userID <= 0 {
return userdomain.ProfileStats{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.userRepository == nil {
return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
return s.userRepository.GetMyProfileStats(ctx, userID)
}
// BatchGetUsers 批量查询用户主状态。
func (s *Service) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) {
if len(userIDs) == 0 {

View File

@ -69,6 +69,84 @@ func (r *Repository) GetCoinSellerProfile(ctx context.Context, userID int64) (ho
return queryCoinSellerProfile(ctx, r.db, "WHERE user_id = ?", userID)
}
// GetUserRoleSummary 用一组索引点查构造我的页角色摘要。
// 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。
func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
app := appcode.FromContext(ctx)
summary := hostdomain.UserRoleSummary{UserID: userID}
var hostStatus string
var hostSource string
var hostAgencyID int64
var ownerAgencyID int64
var bdUserID int64
var bdRole string
var bdStatus string
var coinSellerStatus string
var merchantAssetType string
err := r.db.QueryRowContext(ctx, `
SELECT
COALESCE(h.status, ''),
COALESCE(h.source, ''),
COALESCE(h.current_agency_id, 0),
COALESCE(a.agency_id, 0),
COALESCE(b.user_id, 0),
COALESCE(b.role, ''),
COALESCE(b.status, ''),
COALESCE(c.status, ''),
COALESCE(c.merchant_asset_type, '')
FROM (SELECT ? AS user_id) u
LEFT JOIN host_profiles h
ON h.app_code = ? AND h.user_id = u.user_id
LEFT JOIN agencies a
ON a.app_code = ? AND a.owner_user_id = u.user_id AND a.status = ?
LEFT JOIN bd_profiles b
ON b.app_code = ? AND b.user_id = u.user_id
LEFT JOIN coin_seller_profiles c
ON c.app_code = ? AND c.user_id = u.user_id`,
userID,
app,
app, hostdomain.AgencyStatusActive,
app,
app,
).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdRole, &bdStatus, &coinSellerStatus, &merchantAssetType)
if err != nil {
return hostdomain.UserRoleSummary{}, err
}
summary.HostStatus = hostStatus
summary.IsHost = hostStatus == hostdomain.HostStatusActive
if ownerAgencyID > 0 {
summary.AgencyID = ownerAgencyID
} else {
summary.AgencyID = hostAgencyID
}
summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency))
summary.BDStatus = bdStatus
if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive {
summary.BDID = bdUserID
switch bdRole {
case hostdomain.BDRoleLeader:
summary.IsBD = true
summary.IsBDLeader = true
case hostdomain.BDRoleBD:
summary.IsBD = true
}
}
summary.CoinSellerStatus = coinSellerStatus
summary.IsCoinSeller = coinSellerStatus == hostdomain.CoinSellerStatusActive && merchantAssetType == hostdomain.CoinSellerMerchantAssetType
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM role_invitations
WHERE app_code = ? AND target_user_id = ? AND status = ?`,
app, userID, hostdomain.InvitationStatusPending,
).Scan(&summary.PendingRoleInvitations); err != nil {
return hostdomain.UserRoleSummary{}, err
}
return summary, nil
}
// ListAgencyMembers 读取 Agency 成员关系列表。
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)

View File

@ -32,6 +32,28 @@ func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User
return user, nil
}
// GetMyProfileStats 只读取预聚合统计表,不能实时扫描访客、关注或好友明细。
func (r *Repository) GetMyProfileStats(ctx context.Context, userID int64) (userdomain.ProfileStats, error) {
if r == nil || r.db == nil {
return userdomain.ProfileStats{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
appCode := appcode.FromContext(ctx)
stats := userdomain.ProfileStats{AppCode: appCode, UserID: userID}
err := r.db.QueryRowContext(ctx, `
SELECT visitors_count, following_count, friends_count, updated_at_ms
FROM user_profile_stats
WHERE app_code = ? AND user_id = ?`,
appCode, userID,
).Scan(&stats.VisitorsCount, &stats.FollowingCount, &stats.FriendsCount, &stats.UpdatedAtMs)
if err == sql.ErrNoRows {
return stats, nil
}
if err != nil {
return userdomain.ProfileStats{}, err
}
return stats, nil
}
// BatchGetUsers 批量读取用户主状态,缺失用户不会出现在返回 map 中。
func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error) {

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