完善接口流程

This commit is contained in:
zhx 2026-05-06 21:22:39 +08:00
parent 975d21a63a
commit 15760a55c2
85 changed files with 14809 additions and 5049 deletions

File diff suppressed because it is too large Load Diff

View File

@ -36,8 +36,159 @@ message GetActivityStatusResponse {
string status = 2;
}
// MessageTabSection App tab
message MessageTabSection {
string section = 1;
string title = 2;
int64 unread_count = 3;
string source = 4;
}
// ListMessageTabsRequest tab
message ListMessageTabsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
// ListMessageTabsResponse user/system/activity
message ListMessageTabsResponse {
repeated MessageTabSection sections = 1;
}
// InboxMessage system/activity
message InboxMessage {
string message_id = 1;
string section = 2;
string title = 3;
string summary = 4;
string body = 5;
string icon_url = 6;
string image_url = 7;
string action_type = 8;
string action_param = 9;
bool read = 10;
int64 sent_at_ms = 11;
}
// ListInboxMessagesRequest section inbox
message ListInboxMessagesRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string section = 3;
int32 page_size = 4;
string page_token = 5;
}
// ListInboxMessagesResponse
message ListInboxMessagesResponse {
string section = 1;
repeated InboxMessage items = 2;
string next_page_token = 3;
}
// MarkInboxMessageReadRequest
message MarkInboxMessageReadRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string message_id = 3;
}
// MarkInboxMessageReadResponse
message MarkInboxMessageReadResponse {
string message_id = 1;
bool read = 2;
int64 read_at_ms = 3;
}
// MarkInboxSectionReadRequest section
message MarkInboxSectionReadRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string section = 3;
}
// MarkInboxSectionReadResponse
message MarkInboxSectionReadResponse {
string section = 1;
int64 read_count = 2;
}
// DeleteInboxMessageRequest
message DeleteInboxMessageRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string message_id = 3;
}
// DeleteInboxMessageResponse ID
message DeleteInboxMessageResponse {
string message_id = 1;
bool deleted = 2;
}
// CreateInboxMessageRequest producer_event_id
message CreateInboxMessageRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string producer = 3;
string producer_event_id = 4;
string producer_event_type = 5;
string message_type = 6;
string aggregate_type = 7;
string aggregate_id = 8;
string template_id = 9;
string template_version = 10;
string title = 11;
string summary = 12;
string body = 13;
string icon_url = 14;
string image_url = 15;
string action_type = 16;
string action_param = 17;
int32 priority = 18;
int64 sent_at_ms = 19;
int64 expire_at_ms = 20;
string metadata_json = 21;
}
// CreateInboxMessageResponse producer_event_id created=false
message CreateInboxMessageResponse {
string message_id = 1;
bool created = 2;
}
// CreateFanoutJobRequest fanout command_id
message CreateFanoutJobRequest {
RequestMeta meta = 1;
string command_id = 2;
string message_type = 3;
string target_scope = 4;
string target_filter_json = 5;
string template_snapshot_json = 6;
int32 batch_size = 7;
string created_by = 8;
}
// CreateFanoutJobResponse fanout job
message CreateFanoutJobResponse {
string job_id = 1;
string status = 2;
bool created = 3;
}
// ActivityService RPC
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
rpc GetActivityStatus(GetActivityStatusRequest) returns (GetActivityStatusResponse);
}
// MessageInboxService App system/activity inbox
service MessageInboxService {
rpc ListMessageTabs(ListMessageTabsRequest) returns (ListMessageTabsResponse);
rpc ListInboxMessages(ListInboxMessagesRequest) returns (ListInboxMessagesResponse);
rpc MarkInboxMessageRead(MarkInboxMessageReadRequest) returns (MarkInboxMessageReadResponse);
rpc MarkInboxSectionRead(MarkInboxSectionReadRequest) returns (MarkInboxSectionReadResponse);
rpc DeleteInboxMessage(DeleteInboxMessageRequest) returns (DeleteInboxMessageResponse);
rpc CreateInboxMessage(CreateInboxMessageRequest) returns (CreateInboxMessageResponse);
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
}

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
@ -161,3 +161,337 @@ var ActivityService_ServiceDesc = grpc.ServiceDesc{
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
MessageInboxService_ListMessageTabs_FullMethodName = "/hyapp.activity.v1.MessageInboxService/ListMessageTabs"
MessageInboxService_ListInboxMessages_FullMethodName = "/hyapp.activity.v1.MessageInboxService/ListInboxMessages"
MessageInboxService_MarkInboxMessageRead_FullMethodName = "/hyapp.activity.v1.MessageInboxService/MarkInboxMessageRead"
MessageInboxService_MarkInboxSectionRead_FullMethodName = "/hyapp.activity.v1.MessageInboxService/MarkInboxSectionRead"
MessageInboxService_DeleteInboxMessage_FullMethodName = "/hyapp.activity.v1.MessageInboxService/DeleteInboxMessage"
MessageInboxService_CreateInboxMessage_FullMethodName = "/hyapp.activity.v1.MessageInboxService/CreateInboxMessage"
MessageInboxService_CreateFanoutJob_FullMethodName = "/hyapp.activity.v1.MessageInboxService/CreateFanoutJob"
)
// MessageInboxServiceClient is the client API for MessageInboxService 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.
//
// MessageInboxService 拥有 App system/activity 个人 inbox 事实。
type MessageInboxServiceClient interface {
ListMessageTabs(ctx context.Context, in *ListMessageTabsRequest, opts ...grpc.CallOption) (*ListMessageTabsResponse, error)
ListInboxMessages(ctx context.Context, in *ListInboxMessagesRequest, opts ...grpc.CallOption) (*ListInboxMessagesResponse, error)
MarkInboxMessageRead(ctx context.Context, in *MarkInboxMessageReadRequest, opts ...grpc.CallOption) (*MarkInboxMessageReadResponse, error)
MarkInboxSectionRead(ctx context.Context, in *MarkInboxSectionReadRequest, opts ...grpc.CallOption) (*MarkInboxSectionReadResponse, error)
DeleteInboxMessage(ctx context.Context, in *DeleteInboxMessageRequest, opts ...grpc.CallOption) (*DeleteInboxMessageResponse, error)
CreateInboxMessage(ctx context.Context, in *CreateInboxMessageRequest, opts ...grpc.CallOption) (*CreateInboxMessageResponse, error)
CreateFanoutJob(ctx context.Context, in *CreateFanoutJobRequest, opts ...grpc.CallOption) (*CreateFanoutJobResponse, error)
}
type messageInboxServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMessageInboxServiceClient(cc grpc.ClientConnInterface) MessageInboxServiceClient {
return &messageInboxServiceClient{cc}
}
func (c *messageInboxServiceClient) ListMessageTabs(ctx context.Context, in *ListMessageTabsRequest, opts ...grpc.CallOption) (*ListMessageTabsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListMessageTabsResponse)
err := c.cc.Invoke(ctx, MessageInboxService_ListMessageTabs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) ListInboxMessages(ctx context.Context, in *ListInboxMessagesRequest, opts ...grpc.CallOption) (*ListInboxMessagesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListInboxMessagesResponse)
err := c.cc.Invoke(ctx, MessageInboxService_ListInboxMessages_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) MarkInboxMessageRead(ctx context.Context, in *MarkInboxMessageReadRequest, opts ...grpc.CallOption) (*MarkInboxMessageReadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MarkInboxMessageReadResponse)
err := c.cc.Invoke(ctx, MessageInboxService_MarkInboxMessageRead_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) MarkInboxSectionRead(ctx context.Context, in *MarkInboxSectionReadRequest, opts ...grpc.CallOption) (*MarkInboxSectionReadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MarkInboxSectionReadResponse)
err := c.cc.Invoke(ctx, MessageInboxService_MarkInboxSectionRead_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) DeleteInboxMessage(ctx context.Context, in *DeleteInboxMessageRequest, opts ...grpc.CallOption) (*DeleteInboxMessageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteInboxMessageResponse)
err := c.cc.Invoke(ctx, MessageInboxService_DeleteInboxMessage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) CreateInboxMessage(ctx context.Context, in *CreateInboxMessageRequest, opts ...grpc.CallOption) (*CreateInboxMessageResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateInboxMessageResponse)
err := c.cc.Invoke(ctx, MessageInboxService_CreateInboxMessage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageInboxServiceClient) CreateFanoutJob(ctx context.Context, in *CreateFanoutJobRequest, opts ...grpc.CallOption) (*CreateFanoutJobResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateFanoutJobResponse)
err := c.cc.Invoke(ctx, MessageInboxService_CreateFanoutJob_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// MessageInboxServiceServer is the server API for MessageInboxService service.
// All implementations must embed UnimplementedMessageInboxServiceServer
// for forward compatibility.
//
// MessageInboxService 拥有 App system/activity 个人 inbox 事实。
type MessageInboxServiceServer interface {
ListMessageTabs(context.Context, *ListMessageTabsRequest) (*ListMessageTabsResponse, error)
ListInboxMessages(context.Context, *ListInboxMessagesRequest) (*ListInboxMessagesResponse, error)
MarkInboxMessageRead(context.Context, *MarkInboxMessageReadRequest) (*MarkInboxMessageReadResponse, error)
MarkInboxSectionRead(context.Context, *MarkInboxSectionReadRequest) (*MarkInboxSectionReadResponse, error)
DeleteInboxMessage(context.Context, *DeleteInboxMessageRequest) (*DeleteInboxMessageResponse, error)
CreateInboxMessage(context.Context, *CreateInboxMessageRequest) (*CreateInboxMessageResponse, error)
CreateFanoutJob(context.Context, *CreateFanoutJobRequest) (*CreateFanoutJobResponse, error)
mustEmbedUnimplementedMessageInboxServiceServer()
}
// UnimplementedMessageInboxServiceServer 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 UnimplementedMessageInboxServiceServer struct{}
func (UnimplementedMessageInboxServiceServer) ListMessageTabs(context.Context, *ListMessageTabsRequest) (*ListMessageTabsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMessageTabs not implemented")
}
func (UnimplementedMessageInboxServiceServer) ListInboxMessages(context.Context, *ListInboxMessagesRequest) (*ListInboxMessagesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListInboxMessages not implemented")
}
func (UnimplementedMessageInboxServiceServer) MarkInboxMessageRead(context.Context, *MarkInboxMessageReadRequest) (*MarkInboxMessageReadResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MarkInboxMessageRead not implemented")
}
func (UnimplementedMessageInboxServiceServer) MarkInboxSectionRead(context.Context, *MarkInboxSectionReadRequest) (*MarkInboxSectionReadResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MarkInboxSectionRead not implemented")
}
func (UnimplementedMessageInboxServiceServer) DeleteInboxMessage(context.Context, *DeleteInboxMessageRequest) (*DeleteInboxMessageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteInboxMessage not implemented")
}
func (UnimplementedMessageInboxServiceServer) CreateInboxMessage(context.Context, *CreateInboxMessageRequest) (*CreateInboxMessageResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateInboxMessage not implemented")
}
func (UnimplementedMessageInboxServiceServer) CreateFanoutJob(context.Context, *CreateFanoutJobRequest) (*CreateFanoutJobResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateFanoutJob not implemented")
}
func (UnimplementedMessageInboxServiceServer) mustEmbedUnimplementedMessageInboxServiceServer() {}
func (UnimplementedMessageInboxServiceServer) testEmbeddedByValue() {}
// UnsafeMessageInboxServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to MessageInboxServiceServer will
// result in compilation errors.
type UnsafeMessageInboxServiceServer interface {
mustEmbedUnimplementedMessageInboxServiceServer()
}
func RegisterMessageInboxServiceServer(s grpc.ServiceRegistrar, srv MessageInboxServiceServer) {
// If the following call pancis, it indicates UnimplementedMessageInboxServiceServer 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(&MessageInboxService_ServiceDesc, srv)
}
func _MessageInboxService_ListMessageTabs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListMessageTabsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).ListMessageTabs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_ListMessageTabs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).ListMessageTabs(ctx, req.(*ListMessageTabsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_ListInboxMessages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListInboxMessagesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).ListInboxMessages(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_ListInboxMessages_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).ListInboxMessages(ctx, req.(*ListInboxMessagesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_MarkInboxMessageRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkInboxMessageReadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).MarkInboxMessageRead(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_MarkInboxMessageRead_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).MarkInboxMessageRead(ctx, req.(*MarkInboxMessageReadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_MarkInboxSectionRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MarkInboxSectionReadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).MarkInboxSectionRead(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_MarkInboxSectionRead_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).MarkInboxSectionRead(ctx, req.(*MarkInboxSectionReadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_DeleteInboxMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteInboxMessageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).DeleteInboxMessage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_DeleteInboxMessage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).DeleteInboxMessage(ctx, req.(*DeleteInboxMessageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_CreateInboxMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateInboxMessageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).CreateInboxMessage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_CreateInboxMessage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).CreateInboxMessage(ctx, req.(*CreateInboxMessageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageInboxService_CreateFanoutJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateFanoutJobRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageInboxServiceServer).CreateFanoutJob(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageInboxService_CreateFanoutJob_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageInboxServiceServer).CreateFanoutJob(ctx, req.(*CreateFanoutJobRequest))
}
return interceptor(ctx, in, info, handler)
}
// MessageInboxService_ServiceDesc is the grpc.ServiceDesc for MessageInboxService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var MessageInboxService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.MessageInboxService",
HandlerType: (*MessageInboxServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListMessageTabs",
Handler: _MessageInboxService_ListMessageTabs_Handler,
},
{
MethodName: "ListInboxMessages",
Handler: _MessageInboxService_ListInboxMessages_Handler,
},
{
MethodName: "MarkInboxMessageRead",
Handler: _MessageInboxService_MarkInboxMessageRead_Handler,
},
{
MethodName: "MarkInboxSectionRead",
Handler: _MessageInboxService_MarkInboxSectionRead_Handler,
},
{
MethodName: "DeleteInboxMessage",
Handler: _MessageInboxService_DeleteInboxMessage_Handler,
},
{
MethodName: "CreateInboxMessage",
Handler: _MessageInboxService_CreateInboxMessage_Handler,
},
{
MethodName: "CreateFanoutJob",
Handler: _MessageInboxService_CreateFanoutJob_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.34.2
// protoc v7.34.1
// 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)
@ -937,11 +911,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 {
@ -952,7 +924,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)
@ -1021,11 +993,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 {
@ -1036,7 +1006,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)
@ -1078,11 +1048,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 {
@ -1093,7 +1061,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)
@ -1321,200 +1289,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

@ -407,6 +407,41 @@ message ListRoomsResponse {
string next_cursor = 2;
}
// GetCurrentRoomRequest presence
// gateway user_id user_id
message GetCurrentRoomRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
// GetCurrentRoomResponse App
// JoinRoom presence
message GetCurrentRoomResponse {
bool has_current_room = 1;
string room_id = 2;
int64 room_version = 3;
string role = 4;
string mic_session_id = 5;
string publish_state = 6;
bool need_join_im_group = 7;
bool need_rtc_token = 8;
int64 server_time_ms = 9;
}
// GetRoomSnapshotRequest
// gateway viewer_user_id viewer_user_id
message GetRoomSnapshotRequest {
RequestMeta meta = 1;
string room_id = 2;
int64 viewer_user_id = 3;
}
// GetRoomSnapshotResponse Room Cell presence
message GetRoomSnapshotResponse {
RoomSnapshot room = 1;
int64 server_time_ms = 2;
}
// RoomCommandService
service RoomCommandService {
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
@ -438,4 +473,6 @@ service RoomGuardService {
// RoomQueryService Room Cell
service RoomQueryService {
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
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 v7.34.1
// - protoc v5.27.3
// source: proto/room/v1/room.proto
package roomv1
@ -915,7 +915,9 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
}
const (
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
)
// RoomQueryServiceClient is the client API for RoomQueryService service.
@ -925,6 +927,8 @@ const (
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceClient interface {
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
}
type roomQueryServiceClient struct {
@ -945,6 +949,26 @@ func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsReq
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)
err := c.cc.Invoke(ctx, RoomQueryService_GetCurrentRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRoomSnapshotResponse)
err := c.cc.Invoke(ctx, RoomQueryService_GetRoomSnapshot_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomQueryServiceServer is the server API for RoomQueryService service.
// All implementations must embed UnimplementedRoomQueryServiceServer
// for forward compatibility.
@ -952,6 +976,8 @@ func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsReq
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
type RoomQueryServiceServer interface {
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
mustEmbedUnimplementedRoomQueryServiceServer()
}
@ -965,6 +991,12 @@ type UnimplementedRoomQueryServiceServer struct{}
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRooms not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCurrentRoom not implemented")
}
func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRoomSnapshot not implemented")
}
func (UnimplementedRoomQueryServiceServer) mustEmbedUnimplementedRoomQueryServiceServer() {}
func (UnimplementedRoomQueryServiceServer) testEmbeddedByValue() {}
@ -1004,6 +1036,42 @@ func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, d
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 {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).GetCurrentRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_GetCurrentRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).GetCurrentRoom(ctx, req.(*GetCurrentRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRoomSnapshotRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).GetRoomSnapshot(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_GetRoomSnapshot_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).GetRoomSnapshot(ctx, req.(*GetRoomSnapshotRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomQueryService_ServiceDesc is the grpc.ServiceDesc for RoomQueryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1015,6 +1083,14 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRooms",
Handler: _RoomQueryService_ListRooms_Handler,
},
{
MethodName: "GetCurrentRoom",
Handler: _RoomQueryService_GetCurrentRoom_Handler,
},
{
MethodName: "GetRoomSnapshot",
Handler: _RoomQueryService_GetRoomSnapshot_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto",

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.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)
@ -874,116 +856,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
}
}
}
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

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -120,6 +120,23 @@ message BatchGetUsersResponse {
map<int64, User> users = 1;
}
// ListUserIDsRequest fanout/ user_id
message ListUserIDsRequest {
RequestMeta meta = 1;
string target_scope = 2;
int64 cursor_user_id = 3;
int32 page_size = 4;
int64 region_id = 5;
string country = 6;
}
// ListUserIDsResponse user_id next_cursor_user_id
message ListUserIDsResponse {
repeated int64 user_ids = 1;
int64 next_cursor_user_id = 2;
bool done = 3;
}
// UpdateUserProfileRequest
message UpdateUserProfileRequest {
RequestMeta meta = 1;
@ -167,6 +184,39 @@ message CompleteOnboardingResponse {
AuthToken token = 5;
}
// BindPushTokenRequest token
// push token 线
message BindPushTokenRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string device_id = 3;
string push_token = 4;
string provider = 5;
string platform = 6;
string app_version = 7;
string language = 8;
string timezone = 9;
}
message BindPushTokenResponse {
bool bound = 1;
int64 updated_at_ms = 2;
}
// DeletePushTokenRequest token
// push_token user_id + device_id active token
message DeletePushTokenRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string device_id = 3;
string push_token = 4;
}
message DeletePushTokenResponse {
bool deleted = 1;
int64 updated_at_ms = 2;
}
// Country country_code
message Country {
int64 country_id = 1;
@ -345,11 +395,18 @@ message ExpirePrettyDisplayUserIDResponse {
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
}
// UserDeviceService App token
service UserDeviceService {
rpc BindPushToken(BindPushTokenRequest) returns (BindPushTokenResponse);
rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse);
}
// AppRegistryService gateway package_name -> app_code
service AppRegistryService {
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);

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
@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const (
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
@ -34,6 +35,7 @@ const (
type UserServiceClient interface {
GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*GetUserResponse, error)
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
@ -67,6 +69,16 @@ func (c *userServiceClient) BatchGetUsers(ctx context.Context, in *BatchGetUsers
return out, nil
}
func (c *userServiceClient) ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserIDsResponse)
err := c.cc.Invoke(ctx, UserService_ListUserIDs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserProfileResponse)
@ -105,6 +117,7 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
type UserServiceServer interface {
GetUser(context.Context, *GetUserRequest) (*GetUserResponse, error)
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
@ -124,6 +137,9 @@ func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest)
func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
}
func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserIDs not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserProfile not implemented")
}
@ -190,6 +206,24 @@ func _UserService_BatchGetUsers_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler)
}
func _UserService_ListUserIDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUserIDsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).ListUserIDs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_ListUserIDs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).ListUserIDs(ctx, req.(*ListUserIDsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserProfileRequest)
if err := dec(in); err != nil {
@ -259,6 +293,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "BatchGetUsers",
Handler: _UserService_BatchGetUsers_Handler,
},
{
MethodName: "ListUserIDs",
Handler: _UserService_ListUserIDs_Handler,
},
{
MethodName: "UpdateUserProfile",
Handler: _UserService_UpdateUserProfile_Handler,
@ -276,6 +314,150 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/user/v1/user.proto",
}
const (
UserDeviceService_BindPushToken_FullMethodName = "/hyapp.user.v1.UserDeviceService/BindPushToken"
UserDeviceService_DeletePushToken_FullMethodName = "/hyapp.user.v1.UserDeviceService/DeletePushToken"
)
// UserDeviceServiceClient is the client API for UserDeviceService 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.
//
// UserDeviceService 承载 App 设备推送 token 的绑定和失效。
type UserDeviceServiceClient interface {
BindPushToken(ctx context.Context, in *BindPushTokenRequest, opts ...grpc.CallOption) (*BindPushTokenResponse, error)
DeletePushToken(ctx context.Context, in *DeletePushTokenRequest, opts ...grpc.CallOption) (*DeletePushTokenResponse, error)
}
type userDeviceServiceClient struct {
cc grpc.ClientConnInterface
}
func NewUserDeviceServiceClient(cc grpc.ClientConnInterface) UserDeviceServiceClient {
return &userDeviceServiceClient{cc}
}
func (c *userDeviceServiceClient) BindPushToken(ctx context.Context, in *BindPushTokenRequest, opts ...grpc.CallOption) (*BindPushTokenResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BindPushTokenResponse)
err := c.cc.Invoke(ctx, UserDeviceService_BindPushToken_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userDeviceServiceClient) DeletePushToken(ctx context.Context, in *DeletePushTokenRequest, opts ...grpc.CallOption) (*DeletePushTokenResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeletePushTokenResponse)
err := c.cc.Invoke(ctx, UserDeviceService_DeletePushToken_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserDeviceServiceServer is the server API for UserDeviceService service.
// All implementations must embed UnimplementedUserDeviceServiceServer
// for forward compatibility.
//
// UserDeviceService 承载 App 设备推送 token 的绑定和失效。
type UserDeviceServiceServer interface {
BindPushToken(context.Context, *BindPushTokenRequest) (*BindPushTokenResponse, error)
DeletePushToken(context.Context, *DeletePushTokenRequest) (*DeletePushTokenResponse, error)
mustEmbedUnimplementedUserDeviceServiceServer()
}
// UnimplementedUserDeviceServiceServer 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 UnimplementedUserDeviceServiceServer struct{}
func (UnimplementedUserDeviceServiceServer) BindPushToken(context.Context, *BindPushTokenRequest) (*BindPushTokenResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BindPushToken not implemented")
}
func (UnimplementedUserDeviceServiceServer) DeletePushToken(context.Context, *DeletePushTokenRequest) (*DeletePushTokenResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeletePushToken not implemented")
}
func (UnimplementedUserDeviceServiceServer) mustEmbedUnimplementedUserDeviceServiceServer() {}
func (UnimplementedUserDeviceServiceServer) testEmbeddedByValue() {}
// UnsafeUserDeviceServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to UserDeviceServiceServer will
// result in compilation errors.
type UnsafeUserDeviceServiceServer interface {
mustEmbedUnimplementedUserDeviceServiceServer()
}
func RegisterUserDeviceServiceServer(s grpc.ServiceRegistrar, srv UserDeviceServiceServer) {
// If the following call pancis, it indicates UnimplementedUserDeviceServiceServer 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(&UserDeviceService_ServiceDesc, srv)
}
func _UserDeviceService_BindPushToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BindPushTokenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserDeviceServiceServer).BindPushToken(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserDeviceService_BindPushToken_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserDeviceServiceServer).BindPushToken(ctx, req.(*BindPushTokenRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserDeviceService_DeletePushToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeletePushTokenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserDeviceServiceServer).DeletePushToken(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserDeviceService_DeletePushToken_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserDeviceServiceServer).DeletePushToken(ctx, req.(*DeletePushTokenRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserDeviceService_ServiceDesc is the grpc.ServiceDesc for UserDeviceService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var UserDeviceService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.user.v1.UserDeviceService",
HandlerType: (*UserDeviceServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "BindPushToken",
Handler: _UserDeviceService_BindPushToken_Handler,
},
{
MethodName: "DeletePushToken",
Handler: _UserDeviceService_DeletePushToken_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",
}
const (
AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp"
)

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.6.1
// - protoc v7.34.1
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.27.3
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -366,76 +366,76 @@ type WalletServiceServer interface {
type UnimplementedWalletServiceServer struct{}
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DebitGift not implemented")
return nil, status.Errorf(codes.Unimplemented, "method DebitGift not implemented")
}
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetBalances not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GetBalances not implemented")
}
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminCreditAsset not implemented")
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditAsset not implemented")
}
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
return nil, status.Error(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
}
func (UnimplementedWalletServiceServer) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResources not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListResources not implemented")
}
func (UnimplementedWalletServiceServer) GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetResource not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GetResource not implemented")
}
func (UnimplementedWalletServiceServer) CreateResource(context.Context, *CreateResourceRequest) (*ResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateResource not implemented")
return nil, status.Errorf(codes.Unimplemented, "method CreateResource not implemented")
}
func (UnimplementedWalletServiceServer) UpdateResource(context.Context, *UpdateResourceRequest) (*ResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateResource not implemented")
return nil, status.Errorf(codes.Unimplemented, "method UpdateResource not implemented")
}
func (UnimplementedWalletServiceServer) SetResourceStatus(context.Context, *SetResourceStatusRequest) (*ResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetResourceStatus not implemented")
return nil, status.Errorf(codes.Unimplemented, "method SetResourceStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceGroups(context.Context, *ListResourceGroupsRequest) (*ListResourceGroupsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceGroups not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListResourceGroups not implemented")
}
func (UnimplementedWalletServiceServer) GetResourceGroup(context.Context, *GetResourceGroupRequest) (*GetResourceGroupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetResourceGroup not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GetResourceGroup not implemented")
}
func (UnimplementedWalletServiceServer) CreateResourceGroup(context.Context, *CreateResourceGroupRequest) (*ResourceGroupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateResourceGroup not implemented")
return nil, status.Errorf(codes.Unimplemented, "method CreateResourceGroup not implemented")
}
func (UnimplementedWalletServiceServer) UpdateResourceGroup(context.Context, *UpdateResourceGroupRequest) (*ResourceGroupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateResourceGroup not implemented")
return nil, status.Errorf(codes.Unimplemented, "method UpdateResourceGroup not implemented")
}
func (UnimplementedWalletServiceServer) SetResourceGroupStatus(context.Context, *SetResourceGroupStatusRequest) (*ResourceGroupResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetResourceGroupStatus not implemented")
return nil, status.Errorf(codes.Unimplemented, "method SetResourceGroupStatus not implemented")
}
func (UnimplementedWalletServiceServer) ListGiftConfigs(context.Context, *ListGiftConfigsRequest) (*ListGiftConfigsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListGiftConfigs not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListGiftConfigs not implemented")
}
func (UnimplementedWalletServiceServer) CreateGiftConfig(context.Context, *CreateGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateGiftConfig not implemented")
return nil, status.Errorf(codes.Unimplemented, "method CreateGiftConfig not implemented")
}
func (UnimplementedWalletServiceServer) UpdateGiftConfig(context.Context, *UpdateGiftConfigRequest) (*GiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateGiftConfig not implemented")
return nil, status.Errorf(codes.Unimplemented, "method UpdateGiftConfig not implemented")
}
func (UnimplementedWalletServiceServer) SetGiftConfigStatus(context.Context, *SetGiftConfigStatusRequest) (*GiftConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetGiftConfigStatus not implemented")
return nil, status.Errorf(codes.Unimplemented, "method SetGiftConfigStatus not implemented")
}
func (UnimplementedWalletServiceServer) GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GrantResource not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GrantResource not implemented")
}
func (UnimplementedWalletServiceServer) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GrantResourceGroup not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GrantResourceGroup not implemented")
}
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListUserResources not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListUserResources not implemented")
}
func (UnimplementedWalletServiceServer) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method EquipUserResource not implemented")
return nil, status.Errorf(codes.Unimplemented, "method EquipUserResource not implemented")
}
func (UnimplementedWalletServiceServer) ListResourceGrants(context.Context, *ListResourceGrantsRequest) (*ListResourceGrantsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListResourceGrants not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListResourceGrants not implemented")
}
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeBills not implemented")
}
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
@ -448,7 +448,7 @@ type UnsafeWalletServiceServer interface {
}
func RegisterWalletServiceServer(s grpc.ServiceRegistrar, srv WalletServiceServer) {
// If the following call panics, it indicates UnimplementedWalletServiceServer was
// If the following call pancis, it indicates UnimplementedWalletServiceServer 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.

View File

@ -85,6 +85,8 @@ services:
depends_on:
mysql:
condition: service_healthy
user-service:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service"]
interval: 5s

View File

@ -0,0 +1,382 @@
# App Entry State Machine
本文定义用户从打开 App 到进入主界面的完整状态机。它只描述 App 入口流程、接口调用顺序、跳转条件和异常恢复边界;登录注册细节见 [Login And Registration Technical Design](./auth-login-register-technical-design.md),房间重连细节见 [Room Entry Reconnect Development Requirements](./room-entry-reconnect-development-requirements.md)。
## Goals
- 客户端启动时用确定的状态机决定去登录页、资料补全页、主界面或维护/升级页。
- 未完成资料的用户不能进入房间、IM、RTC、钱包和付费流程。
- access token 过期时优先 refreshrefresh 失败才回登录页。
- 主界面首屏只调用轻量一级接口:房间、消息、我的。
- 腾讯云 IM 登录必须在 `profile_completed=true` 后发生,避免未完成资料用户进入实时系统。
- 房间恢复必须独立于房间列表,不能靠列表猜测用户是否还在某个房间。
## Current Interface Inventory
| Capability | Endpoint | Status | Rule |
| --- | --- | --- | --- |
| 三方登录即注册 | `POST /api/v1/auth/third-party/login` | DONE | 返回 `is_new_user/profile_completed/onboarding_status` |
| refresh token | `POST /api/v1/auth/token/refresh` | DONE | 成功后轮换 refresh token |
| 资料补全 | `POST /api/v1/users/me/onboarding/complete` | DONE | 成功后返回新的 access token不轮换 refresh token |
| 注册国家 | `GET /api/v1/countries` | DONE | 登录前可用 |
| 我的页首屏 | `GET /api/v1/users/me/overview` | DONE | 主界面轻聚合 |
| 房间列表 | `GET /api/v1/rooms?tab=hot/new` | DONE | 需要 completed profile |
| 消息 tab | `GET /api/v1/messages/tabs` | DONE | 需要 completed profile |
| 腾讯 IM UserSig | `GET /api/v1/im/usersig` | DONE | 需要 completed profile |
| App 启动配置 | `GET /api/v1/app/bootstrap` | DONE | 版本、维护、feature flags、配置版本 |
| 当前房间恢复 | `GET /api/v1/rooms/current` | DONE | 判断是否有可恢复房间 |
| 推送 token 绑定 | `POST /api/v1/devices/push-token` / `DELETE /api/v1/devices/push-token` | DONE | 离线消息触达 |
## Top Level State Machine
```mermaid
stateDiagram-v2
[*] --> Launch
Launch --> Bootstrap
Bootstrap --> ForceUpgrade
Bootstrap --> Maintenance
Bootstrap --> TokenCheck
TokenCheck --> LoginRequired: no local token
TokenCheck --> AuthenticatedProbe: access token exists
AuthenticatedProbe --> MainReady: overview ok and profile completed
AuthenticatedProbe --> Refreshing: access token expired or 401
AuthenticatedProbe --> ProfileRequired: PROFILE_REQUIRED or token says profile incomplete
Refreshing --> AuthenticatedProbe: refresh ok
Refreshing --> LoginRequired: refresh failed
LoginRequired --> LoginOrRegister
LoginOrRegister --> ProfileRequired: login ok but profile incomplete
LoginOrRegister --> MainReady: login ok and profile completed
ProfileRequired --> CompleteOnboarding
CompleteOnboarding --> MainReady: success with replacement access token
MainReady --> MainTabs
MainTabs --> IMLogin
MainTabs --> RoomRestoreProbe
```
## Launch Sequence
启动顺序:
1. App 启动,生成或读取 stable `device_id`
2. 调 `GET /api/v1/app/bootstrap`,以 bootstrap 返回的版本、维护、feature flags 和配置版本作为入口决策事实。
3. 如果 bootstrap 返回强更,进入强更页。
4. 如果 bootstrap 返回维护中,进入维护页。
5. 检查本地是否有 access token。
6. 没有 token进入登录页。
7. 有 token调用 `GET /api/v1/users/me/overview` 做认证探测和首屏数据读取。
不要只靠本地缓存判断是否进入主界面。用户可能被禁用、token 可能过期、资料完成状态可能已经变化。
## Bootstrap Contract
当前接口:
```http
GET /api/v1/app/bootstrap
```
登录前也可调用。请求必须携带 App 解析头:
| Header | Meaning |
| --- | --- |
| `X-App-Code` / `X-HY-App-Code` | 已知内部 `app_code` |
| `X-App-Package` / `X-Package-Name` | 包名或 Bundle ID |
| `X-App-Platform` / `X-Platform` | `android` or `ios` |
| `X-App-Version` | 客户端版本 |
| `X-Build-Number` | 构建号 |
响应字段:
```json
{
"app_code": "lalu",
"server_time_ms": 1777996800000,
"force_upgrade": false,
"maintenance": false,
"minimum_version": "1.0.0",
"latest_version": "1.2.0",
"feature_flags": {
"wallet_recharge": true,
"wallet_withdraw": true,
"diamond_exchange": true,
"message_tab": true,
"host_center": true,
"room_create": true
},
"bottom_tabs": [
{"key": "rooms", "title": "房间", "enabled": true},
{"key": "messages", "title": "消息", "enabled": true},
{"key": "me", "title": "我的", "enabled": true}
],
"config_versions": {
"countries": "v1",
"banners": "v1",
"h5_links": "v1",
"resources": "v1",
"gifts": "v1"
}
}
```
bootstrap 只返回配置摘要和版本号,不返回完整礼物、背包资源或 banner 列表。客户端发现版本变化后再拉对应配置接口。
## Authentication Probe
有 access token 时,首选调用:
```http
GET /api/v1/users/me/overview
```
结果处理:
| Result | Client Action |
| --- | --- |
| `200 OK` | 使用 overview 进入主界面 |
| `401 AUTH_REQUIRED` / token expired | 调 refresh |
| `403 PROFILE_REQUIRED` | 进入资料补全页 |
| `403 USER_DISABLED/BANNED` | 进入账号异常页 |
| `5xx/502` | 展示重试页,不清 token |
`overview` 失败时不要直接清空登录态。只有 refresh 明确失败、session revoked 或用户主动 logout 才清本地 token。
## Refresh State
```http
POST /api/v1/auth/token/refresh
```
请求必须带:
- `refresh_token`
- `device_id`
处理规则:
| Result | Client Action |
| --- | --- |
| refresh ok | 替换 access token 和 refresh token重试 `overview` |
| `SESSION_EXPIRED` | 清 token进登录页 |
| `SESSION_REVOKED` | 清 token进登录页 |
| `AUTH_FAILED` | 清 token进登录页 |
| network/5xx | 不清 token展示重试 |
refresh 成功会轮换 refresh token。客户端必须原子保存新 access token 和新 refresh token避免只保存一半导致下次启动失效。
## Login State
三方登录:
```http
POST /api/v1/auth/third-party/login
```
状态跳转:
| Response | Client Action |
| --- | --- |
| `is_new_user=true` | 进入资料补全页 |
| `profile_completed=false` | 进入资料补全页 |
| `profile_completed=true` | 进入主界面 |
服务端不会返回 `NOT_REGISTERED`。三方身份不存在时,该接口原子创建用户、三方绑定、默认短号和 session。
## Profile Required State
资料补全页需要先拉:
```http
GET /api/v1/countries
```
提交:
```http
POST /api/v1/users/me/onboarding/complete
```
成功后:
- 替换响应里的新 access token。
- 保留原 refresh token该接口不轮换 refresh token。
- 重新调用 `GET /api/v1/users/me/overview`
- overview 成功后进入主界面。
未完成资料的 access token 会在 gateway profile gate 被拒绝。客户端不能绕过资料补全去请求房间、IM、RTC、钱包和付费接口。
## Main Tabs Entry
进入主界面后,底部三个一级页使用这些接口:
| Tab | Primary Endpoint | Notes |
| --- | --- | --- |
| 房间 | `GET /api/v1/rooms?tab=hot&limit=20` | 默认 hotnew 用同一接口 |
| 消息 | `GET /api/v1/messages/tabs` | 用户私聊列表走腾讯云 IM SDK |
| 我的 | `GET /api/v1/users/me/overview` | 轻聚合,不查流水、成员、工资 |
主界面首屏可以并行:
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant TIM as Tencent IM
par room tab
C->>G: GET /api/v1/rooms?tab=hot
and message tab
C->>G: GET /api/v1/messages/tabs
and me tab
C->>G: GET /api/v1/users/me/overview
and IM
C->>G: GET /api/v1/im/usersig
C->>TIM: SDK login
end
```
如果当前默认落在“房间” tab可以优先请求房间列表消息和我的延后或并行低优先级请求。
## IM Login State
腾讯云 IM 登录必须发生在 `profile_completed=true` 后。
```http
GET /api/v1/im/usersig
```
规则:
- UserSig 只用于腾讯云 IM SDK 登录。
- 用户私聊会话列表由 SDK 拉取。
- 系统/活动消息由 `/api/v1/messages/tabs``/api/v1/messages` 拉取。
- UserSig 过期时重新调用该接口,不使用 refresh token。
未完成资料用户请求 `/api/v1/im/usersig` 必须返回 `PROFILE_REQUIRED`
## Room Restore State
当前接口:
```http
GET /api/v1/rooms/current
```
用途App 从后台回前台、进程重启、网络恢复时,判断是否有需要恢复的房间。不能通过房间列表猜测当前房间,因为列表是发现页,不表达当前用户 presence。
响应字段:
```json
{
"has_current_room": true,
"room_id": "lalu_room_01",
"room_version": 12,
"role": "audience",
"mic_session_id": "",
"publish_state": "",
"need_join_im_group": true,
"need_rtc_token": false,
"server_time_ms": 1777996800000
}
```
恢复规则:
| State | Client Action |
| --- | --- |
| `has_current_room=false` | 不展示恢复入口 |
| `audience` | 重新登录 IM 并 join group不申请 RTC token |
| `on mic pending/publishing` | 调 `/api/v1/rtc/token`,恢复 RTC随后按麦位状态确认发流 |
| room closed / kicked / banned | 清本地房间态,展示原因或静默回房间列表 |
`rooms/current` 由 room-service 的用户 presence 读模型和 Room Cell 快照二次校验共同决定。该接口只做恢复探测,不写命令日志、不刷新心跳、不隐式 JoinRoom。
## Push Token State
当前接口:
```http
POST /api/v1/devices/push-token
DELETE /api/v1/devices/push-token
```
绑定时机:
- 资料完成后。
- 用户允许系统通知后。
- token 变化、App 重装、语言/时区变化时重新上报。
该能力服务系统/活动消息离线触达,不影响 App 能否进入主界面。
## Error Handling Rules
| Error | Meaning | Client Action |
| --- | --- | --- |
| `AUTH_REQUIRED` | access token 缺失或无效 | 有 refresh 则 refresh否则登录 |
| `SESSION_EXPIRED` | refresh session 过期 | 清 token登录 |
| `SESSION_REVOKED` | refresh session 已吊销 | 清 token登录 |
| `PROFILE_REQUIRED` | 已登录但资料未完成 | 资料补全 |
| `USER_DISABLED` | 用户被禁用 | 账号异常页 |
| `USER_BANNED` | 用户被封禁 | 账号异常页 |
| `UPSTREAM_ERROR` | 上游不可用 | 重试,不清 token |
| network timeout | 网络异常 | 重试,不清 token |
不要因为普通 5xx、网络失败或 bootstrap 失败清空登录态。
## Client Local State
客户端本地至少保存:
- `access_token`
- `refresh_token`
- `session_id`
- `user_id`
- `app_code`
- `profile_completed`
- `onboarding_status`
- `device_id`
- `last_room_id`,只做恢复提示,不能作为 presence 事实
- config version cache
服务端事实优先级高于本地缓存。只要服务端返回 profile required、disabled、banned、session revoked客户端必须更新本地状态。
## Implementation Status
1. 服务端已固定 App 入口状态机文档,客户端入口必须按本文状态转移。
2. gateway 已实现 `GET /api/v1/app/bootstrap`返回版本、维护、feature flags、bottom tabs 和配置版本。
3. gateway 已实现 `GET /api/v1/rooms/current`,只用 token user_id 做当前用户房间恢复探测,不修改房间状态。
4. user-service 已实现 push token 绑定/解绑 RPCgateway 已暴露 `POST/DELETE /api/v1/devices/push-token`
5. App 端下一步按本文状态机接入bootstrap -> token probe -> refresh/login/onboarding -> main tabs -> IM login -> room restore。
6. 端到端 smoke 必须覆盖新用户注册、老用户启动、access token 过期 refresh、profile required、IM login、房间恢复。
## Verification Matrix
| Scenario | Expected |
| --- | --- |
| 首次安装无 token | bootstrap 后进入登录页 |
| 新用户三方登录 | 创建用户,进入资料补全页 |
| 资料补全成功 | 替换 access token进入主界面 |
| access token 过期 | refresh 成功后重试 overview |
| refresh token 过期 | 清 token进入登录页 |
| 未完成资料请求房间列表 | 返回 `PROFILE_REQUIRED` |
| 未完成资料请求 IM UserSig | 返回 `PROFILE_REQUIRED` |
| 主界面打开房间 tab | 拉 `GET /api/v1/rooms?tab=hot` |
| 主界面打开消息 tab | 拉 `GET /api/v1/messages/tabs`,用户私聊走 IM SDK |
| 主界面打开我的 tab | 拉 `GET /api/v1/users/me/overview` |
| App 回前台且有房间 presence | `rooms/current` 返回恢复信息 |
| 普通 5xx 或网络失败 | 展示重试,不清本地 token |
## Critical Rules
- 资料未完成不能进入实时系统、房间、钱包和付费流程。
- `request_id` 只做链路追踪,不做幂等键。
- 登录和 refresh 响应里的 token 必须原子保存。
- CompleteOnboarding 只替换 access token不轮换 refresh token。
- IM 登录在主资料完成之后,用户私聊状态以腾讯云 IM SDK 为准。
- 房间恢复用 `rooms/current`,不要用房间列表代替。
- bootstrap 只返回配置摘要和版本,不返回大列表。

View File

@ -0,0 +1,593 @@
# App Message Tab Architecture
本文定义 App 底部 `消息` tab 的产品边界、服务架构、数据模型、接口契约和开发顺序。消息 tab 固定分为 `用户``系统``活动` 三个分区。当前仓库不自研 IM 长连接,用户私聊仍由腾讯云 IM SDK 承担;后端只持久化系统和活动 inbox并提供列表、未读、已读、删除和运营触达能力。
## Goals
- App 底部消息 tab 展示三个分区:`用户``系统``活动`
- `用户` 分区来自腾讯云 IM C2C conversation list不把私聊内容重复落进业务库。
- `系统``活动` 分区由后端 inbox 持久化,支持分页、未读、已读、删除、过期、撤回和跳转。
- 消息事实必须按 `app_code``user_id` 隔离,不允许跨 App、跨用户读取或写入。
- 消息事实必须在 MySQL可用 Redis 做未读数短 TTL 缓存,但 Redis 不能作为恢复来源。
- 后端外部入口统一在 `gateway-service`,响应使用 `/api/v1` envelope。
- 生产者只发领域事件或内部消息命令message inbox owner 负责模板快照、幂等、入箱和未读数。
## Non-Goals
- 不实现自研 IM 长连接、单聊投递、离线漫游或私聊已读同步。腾讯云 IM 继续负责实时用户消息。
- 不把房间群消息、公屏消息或房间系统消息复制到个人消息 tab。
- 不把后台管理端 `admin_notifications` 复用给 App 用户。后台通知属于 `hyapp_admin`App 消息属于 App 业务库。
- 不允许客户端自报消息归属、未读数、系统消息内容、活动消息内容或目标用户。
- 不在 `room-service``wallet-service``user-service` 内直接写用户 inbox 表;它们只通过 outbox/事件或内部 RPC 触发入箱。
## Current Implementation Snapshot
| Capability | Status | Current Evidence | Gap |
| --- | --- | --- | --- |
| 腾讯云 IM 登录 | `DONE` | `gateway-service` 已有 `/api/v1/im/usersig` | App 侧会话列表仍需客户端 SDK 接入 |
| 用户资料批量查询 | `DONE` | `user-service.BatchGetUsers` + gateway `/api/v1/users/profiles:batch` | 仅返回 IM 会话展示字段 |
| activity-service 底座 | `DONE` | `ActivityService` + `MessageInboxService` 共用 gRPC 入口 | message inbox 事实 owner 已放在 activity-service |
| gateway 消息接口 | `DONE` | `/api/v1/messages/tabs`、列表、已读、read-all、删除 | gateway 只透传 JWT user_id 和 app_code |
| inbox MySQL 表 | `DONE` | `message_templates``user_inbox_messages``message_fanout_jobs` | MySQL 是 system/activity inbox 唯一事实来源 |
| 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer |
| 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 |
| 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 |
| 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + activity fanout workerregion/country/all_active_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 |
这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。
## Message Sections
| Section | Meaning | Source Of Truth | Backend Responsibility |
| --- | --- | --- | --- |
| `user` | 用户之间的单聊消息和会话 | Tencent IM SDK | 签发 UserSig、按需批量补用户资料 |
| `system` | 账号、钱包、房间、host/agency/bd、审核、提现等系统通知 | message inbox MySQL | 持久化、分页、未读、已读、删除、撤回 |
| `activity` | 活动上线、奖励、任务、运营触达 | message inbox MySQL, activity producer | 持久化、分页、未读、已读、删除、跳转参数 |
`user` 分区在客户端通过腾讯云 IM SDK 拉取 conversation list。后端不能把用户私聊复制一份做列表否则会引入隐私、合规、已读同步和消息一致性问题。
`system``activity` 分区使用同一套 inbox 表,通过 `message_type` 区分。活动服务或后台运营系统只生产消息事件,最终个人收件箱由 message inbox owner 落库。
## Service Boundary
```mermaid
graph LR
Client["Client"] --> Gateway["gateway-service"]
Client --> TIM["Tencent IM SDK"]
Gateway --> User["user-service"]
Gateway --> Message["message inbox owner\nactivity-service inbox module first"]
Gateway --> Activity["activity-service"]
TIM --> TIMCloud["Tencent IM"]
User --> UserDB[("hyapp_user")]
Message --> MessageDB[("hyapp_activity message tables")]
Activity --> ActivityDB[("hyapp_activity")]
RoomOutbox[("room_outbox")] --> MQ["MQ / event bus"]
WalletOutbox[("wallet_outbox")] --> MQ
UserOutbox[("user/host outbox")] --> MQ
ActivityOutbox[("activity_outbox")] --> MQ
MQ --> Message
Admin["hyapp-admin-server"] --> Message
```
首版把 `message inbox owner` 放在 `activity-service` 内的 inbox 模块,原因是当前已经有 `activity-service``hyapp_activity` 库,且活动通知天然需要 message fanout。后续如果系统/活动消息量、部署策略或权限模型明显独立,再拆为独立 `message-service`
无论放在哪个进程,都必须遵守以下边界:
- `gateway-service` 只做 HTTP 鉴权、协议转换、app_code 解析、内部 gRPC 编排,不保存 inbox 状态。
- `activity-service inbox module``system/activity` 消息事实 owner负责 MySQL 表、未读数、fanout job 和幂等。
- `user-service` 只提供用户资料、区域、账号状态和批量查询,不承载消息内容。
- `room-service` 只拥有房间状态和房间腾讯 IM 群消息,不直接写个人消息 tab。
- `wallet-service` 只拥有账务事实,通过 wallet outbox 或内部事件触发系统通知。
- `hyapp-admin-server` 只做后台入口、权限和审计;创建 App 消息时必须调用 message inbox owner不能直接写业务表。
## Product Behavior
### Tab Summary
- App 打开消息 tab 时展示三块:`用户``系统``活动`
- `用户` 分区的标题、最近会话、未读数由腾讯云 IM SDK 返回。
- 后端 `/api/v1/messages/tabs` 返回 `system/activity` 未读数,并明确 `user.source=tencent_im_sdk`
- App 总红点 = Tencent IM unread + backend system unread + backend activity unread。
### Message List
- `system/activity` 列表按 `sent_at_ms DESC, inbox_message_id DESC` 排序。
- 只展示当前 `app_code`、当前 `user_id``status=visible`、未删除、未过期的消息。
- 列表项必须包含 title、summary、icon/image、action、read 状态、sent_at_ms。
- `body` 用于详情或富文本,不要求首版做详情页;首版可以在列表响应里返回简短 body。
- 客户端展示跳转动作时只能使用服务端白名单 action不能执行任意 URL 或脚本。
### Read And Delete
- 标记已读是用户侧状态变更,重复请求返回成功,不覆盖首次 `read_at_ms`
- 删除是用户侧隐藏,设置 `deleted_at_ms`,不物理删除事实行。
- 删除未读消息后不再计入未读。
- 过期消息不展示、不计入未读,但保留审计行。
- 撤回消息不展示、不计入未读;撤回原因仅用于审计和后台排查。
## Data Model
### Message Templates
模板只保存服务端可控内容。App 展示标题、摘要、图标和跳转动作时,以消息实例 snapshot 为准,避免模板更新影响历史消息。
```sql
message_templates(
app_code VARCHAR(32) NOT NULL,
template_id VARCHAR(96) NOT NULL,
template_version VARCHAR(64) NOT NULL,
message_type VARCHAR(32) NOT NULL,
locale VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_by VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, template_id, template_version),
KEY idx_message_templates_type_status (app_code, message_type, status),
KEY idx_message_templates_template_status (app_code, template_id, status)
);
```
字段规则:
- `message_type` 只允许 `system``activity`
- `template_version` 必须进入消息实例 snapshot避免模板修改影响历史消息。
- `locale` 首版可以为空;后续多语言按 `app_code + template_id + template_version + locale` 选择。
- `action_type` 必须是服务端白名单枚举,例如 `wallet_withdraw_detail``host_application_detail``activity_detail``room_detail`
- 模板可以被停用,但停用不影响已入箱消息展示。
### User Inbox
```sql
user_inbox_messages(
app_code VARCHAR(32) NOT NULL,
inbox_message_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
message_type VARCHAR(32) NOT NULL,
producer VARCHAR(64) NOT NULL,
producer_event_id VARCHAR(128) NOT NULL,
producer_event_type VARCHAR(96) NOT NULL DEFAULT '',
aggregate_type VARCHAR(64) NOT NULL DEFAULT '',
aggregate_id VARCHAR(128) NOT NULL DEFAULT '',
template_id VARCHAR(96) NOT NULL DEFAULT '',
template_version VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
read_at_ms BIGINT NULL,
deleted_at_ms BIGINT NULL,
sent_at_ms BIGINT NOT NULL,
expire_at_ms BIGINT NULL,
metadata_json JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, inbox_message_id),
UNIQUE KEY uk_user_inbox_producer_event (app_code, user_id, producer, producer_event_id),
KEY idx_user_inbox_list (app_code, user_id, message_type, deleted_at_ms, status, sent_at_ms, inbox_message_id),
KEY idx_user_inbox_unread (app_code, user_id, message_type, read_at_ms, deleted_at_ms, status, expire_at_ms),
KEY idx_user_inbox_aggregate (app_code, user_id, aggregate_type, aggregate_id)
);
```
字段规则:
- `producer_event_id` 是入箱幂等键。钱包提现审核、host 申请、活动奖励等每个业务事件必须生成稳定 ID。
- `producer_event_type` 保留源事件类型,方便排查和消费策略分流。
- `aggregate_type/aggregate_id` 用于同一业务对象查询,例如 `withdraw_request/wd_01``host_application/ha_01`
- `status=visible` 才能被 App 列表看到。`recalled``hidden``expired` 不展示。
- `deleted_at_ms` 是用户侧删除标记,不删除事实行。
- `expire_at_ms` 只影响展示和未读,不影响审计。
- `metadata_json` 只放低敏业务快照不放支付凭证、手机号、证件号、provider receipt 或密钥。
### Fanout Jobs
大范围活动消息不要在 HTTP 请求里同步 fanout。后台只创建任务worker 分批写入,并记录进度和失败原因。
```sql
message_fanout_jobs(
app_code VARCHAR(32) NOT NULL,
job_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
message_type VARCHAR(32) NOT NULL,
target_scope VARCHAR(64) NOT NULL,
target_filter_json JSON NOT NULL,
template_snapshot_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
cursor_user_id BIGINT NOT NULL DEFAULT 0,
total_count BIGINT NOT NULL DEFAULT 0,
success_count BIGINT NOT NULL DEFAULT 0,
failure_count BIGINT NOT NULL DEFAULT 0,
batch_size INT NOT NULL DEFAULT 500,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
locked_by VARCHAR(96) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NOT NULL DEFAULT '',
created_by VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, job_id),
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
);
```
`target_filter_json` 必须由后台服务端生成,不能直接信任客户端或运营前端提交的任意 SQL 条件。首版支持以下 scope
| Scope | Meaning | Target Source |
| --- | --- | --- |
| `single_user` | 单个用户消息 | request target_user_id |
| `user_ids` | 指定用户列表 | admin 上传或活动产出名单 |
| `region` | 区域活动、区域公告 | user-service `users.region_id` |
| `country` | 国家定向通知 | user-service `users.country` |
| `all_active_users` | 全 App 活跃用户 | user-service active users cursor |
### Global Broadcast State
如果全量公告需要“未读、已读、删除”语义,不能只保存一条公告定义后让客户端自行判断。可以二选一:
- P0 方案:全量公告也通过 `message_fanout_jobs` 分批物化到 `user_inbox_messages`,实现简单,查询统一。
- P1 方案:新增 `message_broadcasts``user_broadcast_states`,列表时合并个人 inbox 和广播定义,适合超大规模全量公告。
首版建议采用 P0 物化方案,避免 read-all、delete 和未读数出现两套语义。只有当全量 fanout 成本明显不可接受时,再实现 P1 懒加载模型。
### Unread Counter Cache
未读数可以缓存,但缓存不是事实。
```text
message:unread:{app_code}:{user_id}:{message_type}
```
缓存规则:
- cache miss 时从 `user_inbox_messages` 统计。
- 入箱、已读、read-all、删除、撤回后删除或更新缓存。
- 缓存 TTL 建议 30-120 秒,防止缓存失效遗漏导致长期错误。
- 未读数不得由客户端上报覆盖。
## Internal RPC Surface
首版建议在 `api/proto/activity/v1` 追加 message inbox RPC或者单独新增 `api/proto/message/v1`。如果实现仍放在 `activity-service`,可以先追加到 activity proto但命名必须保持 message inbox 边界。
```proto
service MessageInboxService {
rpc ListMessageTabs(ListMessageTabsRequest) returns (ListMessageTabsResponse);
rpc ListInboxMessages(ListInboxMessagesRequest) returns (ListInboxMessagesResponse);
rpc MarkInboxMessageRead(MarkInboxMessageReadRequest) returns (MarkInboxMessageReadResponse);
rpc MarkInboxSectionRead(MarkInboxSectionReadRequest) returns (MarkInboxSectionReadResponse);
rpc DeleteInboxMessage(DeleteInboxMessageRequest) returns (DeleteInboxMessageResponse);
rpc CreateInboxMessage(CreateInboxMessageRequest) returns (CreateInboxMessageResponse);
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
}
```
RPC 要求:
- 所有请求必须携带 `RequestMeta.app_code``request_id`
- App 用户读写请求必须携带 `user_id`,并由 gateway 使用 JWT 中的用户 ID 填充。
- `CreateInboxMessage` 必须携带 `producer``producer_event_id``message_type``target_user_id`、内容 snapshot 或 template 引用。
- `CreateFanoutJob` 必须携带后台 `command_id`,同 command 重试返回原 job。
- 查询接口不需要 `command_id`,但必须透传 `request_id` 用于追踪。
- 修改 proto 后必须运行 `make proto`,并提交生成后的 `.pb.go` / `_grpc.pb.go`
## App API Surface
所有接口都由 `gateway-service` 暴露,使用 `/api/v1` envelope`{code,message,request_id,data}``app_code` 来自 gateway 的 App 解析或 JWT不接受客户端 body 自报。
### Tab Summary
```http
GET /api/v1/messages/tabs
```
Response data:
```json
{
"sections": [
{
"section": "user",
"title": "用户",
"unread_count": 0,
"source": "tencent_im_sdk"
},
{
"section": "system",
"title": "系统",
"unread_count": 2,
"source": "backend"
},
{
"section": "activity",
"title": "活动",
"unread_count": 1,
"source": "backend"
}
]
}
```
`user.unread_count` 推荐由客户端腾讯云 IM SDK 直接提供。后端返回 `source=tencent_im_sdk`,提醒客户端不要等待后端私聊列表。
### System And Activity List
```http
GET /api/v1/messages?section=system&page_size=20&page_token=
GET /api/v1/messages?section=activity&page_size=20&page_token=
```
Response data:
```json
{
"section": "system",
"items": [
{
"message_id": "msg_01",
"title": "提现审核通过",
"summary": "你的提现申请已通过,请等待人工转账。",
"body": "",
"icon_url": "",
"image_url": "",
"action_type": "wallet_withdraw_detail",
"action_param": "withdrawal_id=wd_01",
"read": false,
"sent_at_ms": 1777996800000
}
],
"next_page_token": ""
}
```
分页使用 `(sent_at_ms, inbox_message_id)` 游标。不要使用 offset大用户消息量上来后 offset 翻页会越来越慢且容易重复或漏项。
### Mark Read
```http
POST /api/v1/messages/{message_id}/read
POST /api/v1/messages/read-all
```
`read-all` body:
```json
{
"section": "system"
}
```
已读接口必须幂等:重复标记已读返回成功,不改变 `read_at_ms` 的首次已读时间。`read-all` 只影响当前 `app_code/user_id/section` 下未删除、未过期、visible 的消息。
### Delete
```http
DELETE /api/v1/messages/{message_id}
```
删除是用户侧隐藏,设置 `deleted_at_ms`。已经删除的消息不进入列表,也不计入未读。删除不存在或不属于当前用户的消息应返回 `NOT_FOUND`,不能泄露其他用户消息是否存在。
### User Message Section
用户消息列表由客户端腾讯云 IM SDK 获取:
```text
Tencent IM SDK getConversationList()
```
后端只补充两个能力:
- `GET /api/v1/im/usersig`:已有,给客户端登录腾讯云 IM。
- `GET /api/v1/users/profiles:batch?user_ids=...`:后续可新增,用于会话列表头像、昵称、短号批量补全。底层复用 `user-service.BatchGetUsers`
`profiles:batch` 只返回展示字段:`user_id``display_user_id``username``avatar``gender``country``region_id``app_code`。不能返回手机号、登录凭证、实名信息或后台身份详情。
## Producer Rules
系统消息生产者:
| Producer | Event | Section | Target |
| --- | --- | --- | --- |
| `wallet-service` | 充值到账、提现审核、提现失败、提现打款完成 | `system` | affected user |
| `user-service` | 登录安全、账号状态、资料审核、host/agency/bd 申请结果 | `system` | affected user |
| `room-service` | 被踢、被禁言、被解除 ban 等个人强相关通知 | `system` | affected user |
| `activity-service` | 活动奖励、任务完成、排行榜奖励 | `activity` | affected user |
| `hyapp-admin-server` | 定向运营通知、全量公告、区域活动通知 | `activity` | selected users |
生产规则:
- 生产者只发领域事件或调用内部 `CreateInboxMessage`,不能直接写 `user_inbox_messages`
- 每个生产者事件必须提供稳定 `producer_event_id`,重复消费只能生成一条 inbox。
- 房间公屏、房间礼物、麦位变更等群内事件仍走腾讯云 IM 群,不进入个人消息 tab。
- `room-service` 只有个人强相关的管理结果才进入消息 tab例如被踢出房间、被禁言这类消息不替代房间 IM 系统消息。
- 钱包消息必须以 wallet 交易或提现单状态为事实来源,不能由 gateway 根据接口返回自行拼通知。
- 后台运营通知必须经过 admin 权限和审计,最终由 message inbox owner 入箱。
## Event To Inbox Flow
```mermaid
sequenceDiagram
participant P as Producer Service
participant O as Producer Outbox
participant M as MQ / Consumer
participant I as Message Inbox Owner
participant DB as message MySQL
participant R as Redis
P->>O: commit domain fact + outbox
M->>O: poll or consume event
M->>I: CreateInboxMessage(producer_event_id)
I->>DB: insert user_inbox_messages with unique key
I->>R: invalidate unread cache
I-->>M: idempotent result
```
一致性要求:
- 生产者业务事实提交不能依赖消息入箱成功。
- 入箱失败由生产者 outbox 或 MQ consumer 重试。
- message inbox owner 以唯一键保证重复消费不重复发消息。
- 如果模板不存在或已停用,消费者应进入 retry 或 dead-letter并告警不能生成空标题消息。
- 如果目标用户不存在、跨 app 或已注销,按业务策略记录 skipped不应无限重试。
## Broadcast And Fanout
消息分发分三种:
| Mode | Use Case | Rule |
| --- | --- | --- |
| `single_user` | 提现审核、申请结果、个人奖励 | 直接写一条 inbox |
| `user_group` | 区域活动、主播群体通知、指定用户列表 | 创建 fanout job分批写 inbox |
| `global` | 全 App 公告 | 首版仍建议 fanout 物化;超大规模后再做 broadcast lazy state |
Fanout worker 要求:
- 每轮锁定一批待执行 job使用 `locked_by/locked_until_ms` 防止多实例重复处理。
- 按 `cursor_user_id` 分页查询目标用户,不使用 offset。
- 每个目标用户的 `producer_event_id` 必须稳定,例如 `fanout:{job_id}:{user_id}`
- 单批失败不能让整个 job 丢失,记录 `failure_count/error_message` 并继续或进入 retry。
- 目标范围按区域/国家选择时,使用 user-service 当前主数据;是否需要冻结目标用户快照由具体活动决定。
- 大型 fanout 配置必须支持 `poll_interval``batch_size``max_retry``lock_ttl`
## Read Consistency
- 用户消息的已读、未读和最后一条消息以腾讯云 IM 为准。
- 系统和活动消息的已读、未读以 `user_inbox_messages` 为准。
- App tab 总红点 = IM SDK unread + backend system unread + backend activity unread。
- 后端不接收客户端上报的 IM unread 作为事实,只能作为埋点或调试信息。
- `read-all` 和删除成功后,必须清理对应 section 的 Redis 未读缓存。
- 列表接口如果读到 Redis 未读数和 MySQL 列表状态短暂不一致,以 MySQL 为最终事实。
## Error Semantics
| Error | Meaning | HTTP Mapping |
| --- | --- | --- |
| `INVALID_SECTION` | section 不是 `system/activity` | 400 |
| `MESSAGE_NOT_FOUND` | 消息不存在、已删除或不属于当前用户 | 404 |
| `MESSAGE_RECALLED` | 消息已撤回,不允许展示或操作 | 404 |
| `PAGE_TOKEN_INVALID` | 游标无法解析或不属于当前查询 | 400 |
| `REQUEST_CONFLICT` | 同 command_id/fanout job 不同 payload | 409 |
| `PRODUCER_EVENT_CONFLICT` | 同 producer_event_id 不同 payload | 409 |
| `MESSAGE_INTERNAL` | DB、缓存或内部未知错误 | 500 |
`request_id` 只做链路追踪,不作为消息入箱幂等键。入箱幂等键必须是 `producer_event_id` 或后台 `command_id`
## Security And Privacy
- `app_code` 由 gateway 解析或 token 携带,不接受客户端 body 覆盖。
- `user_id` 由 JWT 提供,读写接口不能让客户端指定目标用户。
- 后台创建消息必须通过 admin 鉴权和审计,审计留在 `hyapp_admin`
- `action_param` 必须是白名单动作参数,不允许任意外链、脚本、内嵌 HTML。
- 消息 title/summary/body 不得包含支付凭证、provider receipt、敏感证件、完整手机号或密钥。
- 用户删除消息只影响自己,不影响其他用户和后台审计。
- 用户私聊内容不进入后端业务库,不出现在系统/活动消息搜索里。
## Configuration
首版实现需要在 `activity-service` 增加 message inbox 配置,并同步本地 `config.yaml`、Docker `config.docker.yaml`、线上 `config.tencent.example.yaml`
```yaml
message_inbox:
unread_cache_ttl: 60s
fanout_worker:
enabled: true
poll_interval: 1s
batch_size: 500
lock_ttl: 30s
max_retry: 8
```
如果消息 owner 后续拆成独立 `message-service`这些配置迁移到新服务gateway 只需要更新 message gRPC 地址。
## Implementation Order
1. 追加 proto新增 `MessageInboxService`、消息列表、未读、已读、删除、创建入箱、fanout job RPC。
2. 更新 `activity-service` MySQL initdb新增 `message_templates``user_inbox_messages``message_fanout_jobs`
3. 在 `activity-service` 增加 inbox domain/repository/service先实现单用户入箱、列表、未读、已读、删除。
4. 在 `gateway-service` 增加 activity/message client 和 App HTTP`/api/v1/messages/tabs``/api/v1/messages``/api/v1/messages/{id}/read``/api/v1/messages/read-all``DELETE /api/v1/messages/{id}`
5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。
6. 增加 fanout job 和 worker先支持 `region``country``user_ids``all_active_users`
7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。
8. 增加 Redis 未读数缓存和缓存失效逻辑。
9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。
## Verification Matrix
| Scenario | Expected |
| --- | --- |
| 打开消息 tab | 返回 `user/system/activity` 三个分区 |
| `user` 分区 | response 标记 `source=tencent_im_sdk`,不返回私聊内容 |
| 拉系统消息列表 | 只返回当前 `app_code`、当前用户、未删除、未过期、visible 的 system 消息 |
| 拉活动消息列表 | 只返回当前 `app_code`、当前用户、未删除、未过期、visible 的 activity 消息 |
| 重复消费同一 producer event | 只生成一条 inbox 消息 |
| 同 producer event 不同 payload | 返回或记录冲突,不覆盖原消息 |
| 标记已读重复请求 | 返回成功,`read_at_ms` 不被覆盖,未读数不变成负数 |
| read-all | 当前 section 未读全部归零,其他 section 不受影响 |
| 删除未读消息 | 消息不再展示,未读数减少 |
| 删除其他用户消息 | 返回 `NOT_FOUND` |
| 过期消息 | 不展示、不计入未读,但数据库事实保留 |
| 全量活动通知 | fanout job 分批写入,不阻塞 Admin HTTP 请求 |
| 区域活动通知 | 只给当前 app 下目标 region 用户入箱 |
| 用户私聊会话 | App 从腾讯云 IM SDK 获取,后端不返回私聊内容 |
| Redis unread cache miss | 从 MySQL 统计后回填缓存 |
## Test And Release Requirements
涉及 proto 时运行:
```bash
make proto
go test ./...
```
涉及 DB、配置、Docker 时运行:
```bash
docker compose config
docker compose build
```
涉及启动链路时实际拉起:
```bash
docker compose up -d
docker compose ps
docker compose down
```
最小测试覆盖:
- activity-service inbox repository 单元/集成测试:幂等、分页、未读、已读、删除、过期。
- gateway transport 测试JWT 用户 ID 生效、app_code 透传、envelope、错误映射。
- fanout worker 测试:锁 job、分页、重复 target 幂等、失败重试。
- producer consumer 测试:同 event 重复消费只入箱一次。
- profiles batch 测试:只返回安全展示字段。
## Critical Rules
- `user` 分区不落业务库,不复制腾讯云 IM 私聊内容。
- `system``activity` 必须持久化到 MySQL不能只靠 Redis 或推送。
- 所有 inbox 事实必须带 `app_code`
- 已读和删除必须幂等。
- 大范围活动消息必须异步 fanout。
- 房间群消息仍走腾讯云 IM 群,不进入个人消息 tab。
- `request_id` 只做追踪,不做幂等。
- 后台运营消息必须有 admin 鉴权和审计。
- 生产者不能直接写 inbox 表,只能发事件或调用 message inbox owner。

View File

@ -0,0 +1,296 @@
# 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": {},
"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
},
"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` |
| `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 |
| --- | --- | --- |
| `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

@ -17,6 +17,8 @@ produces:
tags:
- name: activity
description: ActivityService活动服务同步查询接口。
- name: messages
description: MessageInboxServiceApp system/activity 个人 inbox 内部接口。
- name: health
description: 标准 grpc.health.v1.Health。
responses:
@ -65,6 +67,146 @@ paths:
$ref: "#/definitions/GetActivityStatusResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/ListMessageTabs:
post:
tags:
- messages
summary: 查询 App 消息 tab 摘要
operationId: activityListMessageTabs
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/ListMessageTabs
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/ListMessageTabsRequest"
responses:
"200":
description: 固定返回 user/system/activity 三个分区。
schema:
$ref: "#/definitions/ListMessageTabsResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/ListInboxMessages:
post:
tags:
- messages
summary: 分页查询 system/activity inbox
operationId: activityListInboxMessages
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/ListInboxMessages
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/ListInboxMessagesRequest"
responses:
"200":
description: 返回当前页消息和下一页游标。
schema:
$ref: "#/definitions/ListInboxMessagesResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/MarkInboxMessageRead:
post:
tags:
- messages
summary: 标记单条 inbox 消息已读
operationId: activityMarkInboxMessageRead
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/MarkInboxMessageRead
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/MarkInboxMessageReadRequest"
responses:
"200":
description: 重复标记成功且不覆盖首次 read_at_ms。
schema:
$ref: "#/definitions/MarkInboxMessageReadResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/MarkInboxSectionRead:
post:
tags:
- messages
summary: 标记一个消息分区全部已读
operationId: activityMarkInboxSectionRead
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/MarkInboxSectionRead
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/MarkInboxSectionReadRequest"
responses:
"200":
description: 返回本次实际更新的消息数。
schema:
$ref: "#/definitions/MarkInboxSectionReadResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/DeleteInboxMessage:
post:
tags:
- messages
summary: 删除当前用户可见 inbox 消息
operationId: activityDeleteInboxMessage
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/DeleteInboxMessage
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/DeleteInboxMessageRequest"
responses:
"200":
description: 删除是用户侧隐藏,不物理删除事实行。
schema:
$ref: "#/definitions/DeleteInboxMessageResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/CreateInboxMessage:
post:
tags:
- messages
summary: 生产者创建单用户 inbox 消息
operationId: activityCreateInboxMessage
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/CreateInboxMessage
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/CreateInboxMessageRequest"
responses:
"200":
description: 同 producer_event_id 重试返回原 message_id。
schema:
$ref: "#/definitions/CreateInboxMessageResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.activity.v1.MessageInboxService/CreateFanoutJob:
post:
tags:
- messages
summary: 创建后台 fanout 任务
operationId: activityCreateFanoutJob
x-grpc-full-method: /hyapp.activity.v1.MessageInboxService/CreateFanoutJob
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/CreateFanoutJobRequest"
responses:
"200":
description: 同 command_id 重试返回原 job_id。
schema:
$ref: "#/definitions/CreateFanoutJobResponse"
default:
$ref: "#/responses/GRPCError"
/grpc.health.v1.Health/Check:
post:
tags:
@ -119,6 +261,9 @@ definitions:
sent_at_ms:
type: integer
format: int64
app_code:
type: string
description: 调用方解析出的 App 租户键,所有 inbox 事实必须按该字段隔离。
PingActivityRequest:
type: object
properties:
@ -148,6 +293,280 @@ definitions:
status:
type: string
description: 当前活动状态;具体业务活动会扩展自己的状态枚举。
MessageTabSection:
type: object
properties:
section:
type: string
enum:
- user
- system
- activity
title:
type: string
unread_count:
type: integer
format: int64
source:
type: string
enum:
- tencent_im_sdk
- backend
ListMessageTabsRequest:
type: object
required:
- user_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
ListMessageTabsResponse:
type: object
properties:
sections:
type: array
items:
$ref: "#/definitions/MessageTabSection"
InboxMessage:
type: object
properties:
message_id:
type: string
section:
type: string
enum:
- system
- activity
title:
type: string
summary:
type: string
body:
type: string
icon_url:
type: string
image_url:
type: string
action_type:
type: string
action_param:
type: string
read:
type: boolean
sent_at_ms:
type: integer
format: int64
ListInboxMessagesRequest:
type: object
required:
- user_id
- section
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
section:
type: string
enum:
- system
- activity
page_size:
type: integer
format: int32
page_token:
type: string
ListInboxMessagesResponse:
type: object
properties:
section:
type: string
items:
type: array
items:
$ref: "#/definitions/InboxMessage"
next_page_token:
type: string
MarkInboxMessageReadRequest:
type: object
required:
- user_id
- message_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
message_id:
type: string
MarkInboxMessageReadResponse:
type: object
properties:
message_id:
type: string
read:
type: boolean
read_at_ms:
type: integer
format: int64
MarkInboxSectionReadRequest:
type: object
required:
- user_id
- section
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
section:
type: string
enum:
- system
- activity
MarkInboxSectionReadResponse:
type: object
properties:
section:
type: string
read_count:
type: integer
format: int64
DeleteInboxMessageRequest:
type: object
required:
- user_id
- message_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
message_id:
type: string
DeleteInboxMessageResponse:
type: object
properties:
message_id:
type: string
deleted:
type: boolean
CreateInboxMessageRequest:
type: object
required:
- target_user_id
- producer
- producer_event_id
- message_type
properties:
meta:
$ref: "#/definitions/RequestMeta"
target_user_id:
type: integer
format: int64
producer:
type: string
producer_event_id:
type: string
producer_event_type:
type: string
message_type:
type: string
enum:
- system
- activity
aggregate_type:
type: string
aggregate_id:
type: string
template_id:
type: string
template_version:
type: string
title:
type: string
summary:
type: string
body:
type: string
icon_url:
type: string
image_url:
type: string
action_type:
type: string
action_param:
type: string
priority:
type: integer
format: int32
sent_at_ms:
type: integer
format: int64
expire_at_ms:
type: integer
format: int64
metadata_json:
type: string
description: 低敏业务快照 JSON 字符串。
CreateInboxMessageResponse:
type: object
properties:
message_id:
type: string
created:
type: boolean
CreateFanoutJobRequest:
type: object
required:
- command_id
- message_type
- target_scope
- target_filter_json
- template_snapshot_json
- created_by
properties:
meta:
$ref: "#/definitions/RequestMeta"
command_id:
type: string
message_type:
type: string
enum:
- system
- activity
target_scope:
type: string
enum:
- single_user
- user_ids
- region
- country
- all_active_users
target_filter_json:
type: string
template_snapshot_json:
type: string
batch_size:
type: integer
format: int32
created_by:
type: string
CreateFanoutJobResponse:
type: object
properties:
job_id:
type: string
status:
type: string
created:
type: boolean
HealthCheckRequest:
type: object
properties:

View File

@ -31,12 +31,16 @@ tags:
description: App 注册页国家选择公开读接口。
- name: app-config
description: App 运行时配置公开读接口。
- name: devices
description: App 设备推送 token 绑定和失效。
- name: rooms
description: 房间命令入口,最终由 room-service 执行。
- name: wallet
description: App 钱包余额、币商转账和充值口径入口。
- name: resources
description: App 资源库、资源组、礼物配置和我的资源入口。
- name: messages
description: App 消息 tab后端只负责 system/activity inbox用户私聊来自腾讯云 IM SDK。
securityDefinitions:
BearerAuth:
type: apiKey
@ -330,6 +334,20 @@ paths:
$ref: "#/definitions/CountryListEnvelope"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/app/bootstrap:
get:
tags:
- app-config
summary: 获取 App 启动配置摘要
operationId: getAppBootstrap
description: 公开读接口只返回版本、维护、feature flags、底部 tab 和配置版本号,不返回 banner、礼物或资源大列表。
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回 App 启动状态机需要的轻量配置摘要。
schema:
$ref: "#/definitions/AppBootstrapEnvelope"
/api/v1/app/h5-links:
get:
tags:
@ -508,6 +526,63 @@ paths:
$ref: "#/responses/Unauthorized"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/devices/push-token:
post:
tags:
- devices
summary: 绑定设备推送 token
operationId: bindPushToken
description: 资料完成后由客户端上报系统推送 tokengateway 只使用 access token 中的用户身份user-service 负责落库。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/BindPushTokenRequest"
responses:
"200":
description: 绑定成功。
schema:
$ref: "#/definitions/BindPushTokenEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
delete:
tags:
- devices
summary: 删除设备推送 token
operationId: deletePushToken
description: 登出、关闭通知或 token 失效时调用;`push_token` 可为空,为空时按当前用户和 `device_id` 失效。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/DeletePushTokenRequest"
responses:
"200":
description: 删除或失效处理完成。
schema:
$ref: "#/definitions/DeletePushTokenEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/tencent-im/callback:
post:
tags:
@ -559,6 +634,35 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/profiles:batch:
get:
tags:
- users
summary: 批量查询会话展示资料
operationId: batchUserProfiles
description: 用于腾讯云 IM 会话列表补头像、昵称和短号;只返回展示字段,不返回登录、实名或后台身份信息。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: user_ids
in: query
required: true
type: string
description: 逗号分隔或重复传入的用户 ID最多 100 个。
responses:
"200":
description: 查询成功。
schema:
$ref: "#/definitions/UserProfilesBatchEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/identity:
get:
tags:
@ -604,6 +708,32 @@ paths:
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/overview:
get:
tags:
- users
summary: 我的页首屏聚合摘要
operationId: getMyOverview
description: 聚合当前用户资料、固定三类钱包余额、Host/Agency/BD/币商身份、消息红点和入口显隐。该接口不查流水、不扫成员、不计算工资;钱包、角色和消息上游失败时按字段 `unavailable=true` 局部降级。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data` 返回我的页首屏渲染摘要。
schema:
$ref: "#/definitions/MyOverviewEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/onboarding/complete:
post:
tags:
@ -763,6 +893,62 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/current:
get:
tags:
- rooms
summary: 查询当前可恢复房间
operationId: getCurrentRoom
description: App 启动、回前台或网络恢复时调用gateway 使用 access token user_idroom-service 用 presence 读模型和 Room Cell 快照二次校验,不会隐式 JoinRoom。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功,`data.has_current_room=false` 表示无需恢复。
schema:
$ref: "#/definitions/CurrentRoomEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/snapshot:
get:
tags:
- rooms
summary: 查询房间完整快照
operationId: getRoomSnapshot
description: 房间页主动同步当前 Room Cell 快照;只读,不刷新 heartbeat不隐式 JoinRoom。gateway 使用 access token user_id 作为 viewerroom-service 要求 viewer 仍在该房间。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: room_id
in: query
required: true
type: string
pattern: "^[A-Za-z0-9_-]{1,48}$"
maxLength: 48
responses:
"200":
description: 查询成功,`data.room` 返回完整房间快照。
schema:
$ref: "#/definitions/RoomSnapshotEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
$ref: "#/responses/Conflict"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/rooms/create:
post:
tags:
@ -1470,6 +1656,149 @@ paths:
$ref: "#/responses/NotFound"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/messages/tabs:
get:
tags:
- messages
summary: 消息 tab 分区摘要
operationId: listMessageTabs
description: 返回 user/system/activity 三个分区user 分区只声明来源为腾讯云 IM SDK不返回私聊内容。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
responses:
"200":
description: 查询成功。
schema:
$ref: "#/definitions/MessageTabsEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/messages:
get:
tags:
- messages
summary: system/activity 消息列表
operationId: listInboxMessages
description: 只读取当前 app_code 和当前用户下 visible、未删除、未过期的 system/activity 消息,按 sent_at_ms 倒序游标分页。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: section
in: query
required: true
type: string
enum: [system, activity]
- name: page_size
in: query
required: false
type: integer
format: int32
- name: page_token
in: query
required: false
type: string
responses:
"200":
description: 查询成功。
schema:
$ref: "#/definitions/MessageListEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/messages/read-all:
post:
tags:
- messages
summary: 标记分区全部已读
operationId: markInboxSectionRead
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: body
in: body
required: true
schema:
$ref: "#/definitions/MessageReadAllRequest"
responses:
"200":
description: 已读成功;重复请求成功且 read_count 可为 0。
schema:
$ref: "#/definitions/MessageReadAllEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/messages/{message_id}/read:
post:
tags:
- messages
summary: 标记单条消息已读
operationId: markInboxMessageRead
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: message_id
in: path
required: true
type: string
responses:
"200":
description: 已读成功;重复请求不覆盖首次 read_at_ms。
schema:
$ref: "#/definitions/MessageReadEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/messages/{message_id}:
delete:
tags:
- messages
summary: 删除单条消息
operationId: deleteInboxMessage
description: 删除是当前用户侧隐藏,不物理删除 inbox 事实;不存在、已删除或不属于当前用户均返回 NOT_FOUND。
security:
- BearerAuth: []
parameters:
- $ref: "#/parameters/RequestIDHeader"
- name: message_id
in: path
required: true
type: string
responses:
"200":
description: 删除成功。
schema:
$ref: "#/definitions/MessageDeleteEnvelope"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/wallet/coin-seller/transfer:
post:
tags:
@ -1846,6 +2175,73 @@ definitions:
type: integer
format: int64
description: 上传文件字节数。
BindPushTokenRequest:
type: object
required:
- device_id
- push_token
- platform
properties:
device_id:
type: string
maxLength: 128
description: App 安装级稳定 ID。
push_token:
type: string
maxLength: 512
description: APNS/FCM 等系统推送 token。
provider:
type: string
maxLength: 32
description: 可选;为空时 android 默认 fcmios 默认 apns。
platform:
type: string
enum:
- android
- ios
app_version:
type: string
maxLength: 64
language:
type: string
maxLength: 32
timezone:
type: string
maxLength: 64
DeletePushTokenRequest:
type: object
required:
- device_id
properties:
device_id:
type: string
maxLength: 128
push_token:
type: string
maxLength: 512
description: 可选;为空时按当前用户和 device_id 失效 active token。
BindPushTokenData:
type: object
required:
- bound
- updated_at_ms
properties:
bound:
type: boolean
updated_at_ms:
type: integer
format: int64
DeletePushTokenData:
type: object
required:
- deleted
- updated_at_ms
properties:
deleted:
type: boolean
updated_at_ms:
type: integer
format: int64
TencentIMCallbackRequest:
type: object
properties:
@ -1970,6 +2366,146 @@ definitions:
is_coin_seller:
type: boolean
description: 用户是否拥有 active 币商身份。
MyOverviewRolesData:
type: object
required:
- is_host
- is_agent
- is_bd
- is_bd_leader
- is_coin_seller
properties:
is_host:
type: boolean
is_agent:
type: boolean
is_bd:
type: boolean
is_bd_leader:
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:
type: string
enum:
- COIN
- DIAMOND
- USD_BALANCE
available_amount:
type: integer
format: int64
frozen_amount:
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 为空。
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 返回。
MyOverviewEntryData:
type: object
required:
- key
- title
- visible
- badge_count
properties:
key:
type: string
enum:
- wallet
- host_center
- apply_host
- agency_center
- bd_center
- bd_leader_center
- coin_seller_center
- backpack
- settings
- support
title:
type: string
visible:
type: boolean
badge_count:
type: integer
format: int64
MyOverviewData:
type: object
required:
- profile
- wallet
- roles
- badges
- entries
- server_time_ms
properties:
profile:
$ref: "#/definitions/UserProfileData"
wallet:
$ref: "#/definitions/MyOverviewWalletData"
roles:
$ref: "#/definitions/MyOverviewRolesData"
badges:
$ref: "#/definitions/MyOverviewBadgesData"
entries:
type: array
items:
$ref: "#/definitions/MyOverviewEntryData"
server_time_ms:
type: integer
format: int64
UserProfileData:
type: object
properties:
@ -2041,6 +2577,172 @@ definitions:
next_country_change_allowed_at_ms:
type: integer
format: int64
UserProfileBatchData:
type: object
required:
- user_id
- display_user_id
- username
- app_code
properties:
user_id:
type: string
description: 数值型用户 ID 的字符串形式,避免客户端 JS 精度丢失。
display_user_id:
type: string
username:
type: string
avatar:
type: string
gender:
type: string
country:
type: string
region_id:
type: integer
format: int64
app_code:
type: string
description: 腾讯云 IM 会话列表展示资料;不包含手机号、登录凭证、实名或后台身份字段。
UserProfilesBatchData:
type: object
required:
- profiles
properties:
profiles:
type: array
items:
$ref: "#/definitions/UserProfileBatchData"
MessageTabSectionData:
type: object
required:
- section
- title
- unread_count
- source
properties:
section:
type: string
enum:
- user
- system
- activity
title:
type: string
unread_count:
type: integer
format: int64
source:
type: string
enum:
- tencent_im_sdk
- backend
MessageTabsData:
type: object
required:
- sections
properties:
sections:
type: array
items:
$ref: "#/definitions/MessageTabSectionData"
InboxMessageData:
type: object
required:
- message_id
- title
- summary
- read
- sent_at_ms
properties:
message_id:
type: string
title:
type: string
summary:
type: string
body:
type: string
icon_url:
type: string
image_url:
type: string
action_type:
type: string
description: 服务端白名单动作,例如 wallet_withdraw_detail、activity_detail、room_detail。
action_param:
type: string
read:
type: boolean
sent_at_ms:
type: integer
format: int64
MessageListData:
type: object
required:
- section
- items
- next_page_token
properties:
section:
type: string
enum:
- system
- activity
items:
type: array
items:
$ref: "#/definitions/InboxMessageData"
next_page_token:
type: string
MessageReadAllRequest:
type: object
required:
- section
properties:
section:
type: string
enum:
- system
- activity
MessageReadData:
type: object
required:
- message_id
- read
- read_at_ms
properties:
message_id:
type: string
read:
type: boolean
read_at_ms:
type: integer
format: int64
MessageReadAllData:
type: object
required:
- section
- read_count
properties:
section:
type: string
enum:
- system
- activity
read_count:
type: integer
format: int64
MessageDeleteData:
type: object
required:
- message_id
- deleted
properties:
message_id:
type: string
deleted:
type: boolean
CountryData:
type: object
properties:
@ -2106,6 +2808,47 @@ definitions:
total:
type: integer
format: int32
AppBootstrapTab:
type: object
properties:
key:
type: string
enum:
- rooms
- messages
- me
title:
type: string
enabled:
type: boolean
AppBootstrapData:
type: object
properties:
app_code:
type: string
server_time_ms:
type: integer
format: int64
force_upgrade:
type: boolean
maintenance:
type: boolean
minimum_version:
type: string
latest_version:
type: string
feature_flags:
type: object
additionalProperties:
type: boolean
bottom_tabs:
type: array
items:
$ref: "#/definitions/AppBootstrapTab"
config_versions:
type: object
additionalProperties:
type: string
CompleteOnboardingRequest:
type: object
required:
@ -2817,6 +3560,59 @@ definitions:
version:
type: integer
format: int64
CurrentRoomData:
type: object
required:
- has_current_room
- room_id
- room_version
- role
- mic_session_id
- publish_state
- need_join_im_group
- need_rtc_token
- server_time_ms
properties:
has_current_room:
type: boolean
room_id:
type: string
room_version:
type: integer
format: int64
role:
type: string
enum:
- owner
- host
- admin
- audience
mic_session_id:
type: string
publish_state:
type: string
enum:
- ""
- pending_publish
- publishing
need_join_im_group:
type: boolean
need_rtc_token:
type: boolean
server_time_ms:
type: integer
format: int64
RoomSnapshotData:
type: object
required:
- room
- server_time_ms
properties:
room:
$ref: "#/definitions/RoomSnapshot"
server_time_ms:
type: integer
format: int64
CreateRoomResponse:
type: object
properties:
@ -2998,6 +3794,20 @@ definitions:
properties:
data:
$ref: "#/definitions/UploadFileData"
BindPushTokenEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/BindPushTokenData"
DeletePushTokenEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/DeletePushTokenData"
UserIdentityEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -3012,6 +3822,13 @@ definitions:
properties:
data:
$ref: "#/definitions/UserHostIdentityData"
MyOverviewEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MyOverviewData"
UserProfileEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -3019,6 +3836,48 @@ definitions:
properties:
data:
$ref: "#/definitions/UserProfileData"
UserProfilesBatchEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/UserProfilesBatchData"
MessageTabsEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MessageTabsData"
MessageListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MessageListData"
MessageReadEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MessageReadData"
MessageReadAllEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MessageReadAllData"
MessageDeleteEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/MessageDeleteData"
CompleteOnboardingEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -3033,6 +3892,13 @@ definitions:
properties:
data:
$ref: "#/definitions/CountryListData"
AppBootstrapEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/AppBootstrapData"
H5LinkListEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
@ -3082,6 +3948,20 @@ definitions:
properties:
data:
$ref: "#/definitions/CreateRoomResponse"
CurrentRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/CurrentRoomData"
RoomSnapshotEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/RoomSnapshotData"
JoinRoomEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -17,6 +17,8 @@ produces:
tags:
- name: room-command
description: RoomCommandService改变房间状态的命令接口。
- name: room-query
description: RoomQueryService不改变 Room Cell 状态的读模型查询。
- name: room-guard
description: RoomGuardService腾讯云 IM 入群和发言前的同步守卫。
- name: health
@ -367,6 +369,48 @@ paths:
$ref: "#/definitions/VerifyRoomPresenceResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomQueryService/GetCurrentRoom:
post:
tags:
- room-query
summary: 查询用户当前可恢复房间
operationId: roomGetCurrentRoom
description: 启动、回前台和网络恢复时使用;只读 room_user_presence 投影并用 Room Cell 快照二次校验,不会隐式 JoinRoom 或刷新 presence。
x-grpc-full-method: /hyapp.room.v1.RoomQueryService/GetCurrentRoom
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/GetCurrentRoomRequest"
responses:
"200":
description: 返回是否存在可恢复房间,以及是否需要重新入 IM 群和重新签 RTC token。
schema:
$ref: "#/definitions/GetCurrentRoomResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.room.v1.RoomQueryService/GetRoomSnapshot:
post:
tags:
- room-query
summary: 查询房间完整快照
operationId: roomGetRoomSnapshot
description: 房间页主动同步使用;只读 Room Cell/snapshot不刷新 presence不隐式 JoinRoomviewer 必须仍在该房间。
x-grpc-full-method: /hyapp.room.v1.RoomQueryService/GetRoomSnapshot
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/GetRoomSnapshotRequest"
responses:
"200":
description: 返回当前完整房间快照。
schema:
$ref: "#/definitions/GetRoomSnapshotResponse"
default:
$ref: "#/responses/GRPCError"
/grpc.health.v1.Health/Check:
post:
tags:
@ -921,6 +965,74 @@ definitions:
room_version:
type: integer
format: int64
GetCurrentRoomRequest:
type: object
required:
- meta
- user_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
description: 由 gateway 从 token user_id 注入,客户端不能自报。
GetCurrentRoomResponse:
type: object
properties:
has_current_room:
type: boolean
room_id:
type: string
room_version:
type: integer
format: int64
role:
type: string
description: 用户在房间快照中的角色。
mic_session_id:
type: string
description: 用户当前在麦时返回;客户端确认发流必须带回该 session。
publish_state:
type: string
enum:
- pending_publish
- publishing
- idle
need_join_im_group:
type: boolean
description: true 表示客户端应重新确保加入腾讯云 IM 群。
need_rtc_token:
type: boolean
description: true 表示用户处于 pending_publish/publishing需要重新获取 RTC token。
server_time_ms:
type: integer
format: int64
GetRoomSnapshotRequest:
type: object
required:
- meta
- room_id
- viewer_user_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
room_id:
type: string
pattern: "^[A-Za-z0-9_-]{1,48}$"
maxLength: 48
viewer_user_id:
type: integer
format: int64
description: 由 gateway 从 token user_id 注入,客户端不能自报。
GetRoomSnapshotResponse:
type: object
properties:
room:
$ref: "#/definitions/RoomSnapshot"
server_time_ms:
type: integer
format: int64
RoomSystemMessage:
type: object
properties:

View File

@ -18,6 +18,8 @@ tags:
description: AuthService登录注册和 token 生命周期。
- name: users
description: UserService用户主状态读取。
- name: user-devices
description: UserDeviceServiceApp 设备推送 token 绑定和失效。
- name: user-identity
description: UserIdentityService展示短号解析和变更。
- name: country-admin
@ -174,6 +176,26 @@ paths:
$ref: "#/definitions/BatchGetUsersResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserService/ListUserIDs:
post:
tags:
- users
summary: 后台任务按 user_id 游标查询目标用户
operationId: userListUserIDs
x-grpc-full-method: /hyapp.user.v1.UserService/ListUserIDs
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/ListUserIDsRequest"
responses:
"200":
description: 返回递增 user_id 页;调用方用 next_cursor_user_id 继续。
schema:
$ref: "#/definitions/ListUserIDsResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserService/UpdateUserProfile:
post:
tags:
@ -236,6 +258,48 @@ paths:
$ref: "#/definitions/CompleteOnboardingResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserDeviceService/BindPushToken:
post:
tags:
- user-devices
summary: 绑定当前登录用户的系统推送 token
operationId: userBindPushToken
description: push token 只服务离线触达,不作为登录凭证或设备身份凭证;同一 provider/token 被新设备绑定时会失效旧归属。
x-grpc-full-method: /hyapp.user.v1.UserDeviceService/BindPushToken
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/BindPushTokenRequest"
responses:
"200":
description: 绑定成功或幂等更新成功。
schema:
$ref: "#/definitions/BindPushTokenResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserDeviceService/DeletePushToken:
post:
tags:
- user-devices
summary: 失效当前登录用户的系统推送 token
operationId: userDeletePushToken
description: push_token 为空时按 user_id + device_id 失效该设备的 active token。
x-grpc-full-method: /hyapp.user.v1.UserDeviceService/DeletePushToken
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/DeletePushTokenRequest"
responses:
"200":
description: 删除成功;没有匹配 token 时返回 deleted=false。
schema:
$ref: "#/definitions/DeletePushTokenResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.CountryAdminService/CreateCountry:
post:
tags:
@ -933,6 +997,44 @@ definitions:
type: object
additionalProperties:
$ref: "#/definitions/User"
ListUserIDsRequest:
type: object
required:
- target_scope
properties:
meta:
$ref: "#/definitions/RequestMeta"
target_scope:
type: string
enum:
- all_active_users
- region
- country
cursor_user_id:
type: integer
format: int64
page_size:
type: integer
format: int32
region_id:
type: integer
format: int64
country:
type: string
description: "`target_scope=country` 时使用的国家码。"
ListUserIDsResponse:
type: object
properties:
user_ids:
type: array
items:
type: integer
format: int64
next_cursor_user_id:
type: integer
format: int64
done:
type: boolean
UpdateUserProfileRequest:
type: object
properties:
@ -987,6 +1089,80 @@ definitions:
format: int64
onboarding_status:
type: string
BindPushTokenRequest:
type: object
required:
- meta
- user_id
- device_id
- push_token
- platform
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
device_id:
type: string
maxLength: 128
description: App 安装维度稳定 ID不是登录凭证。
push_token:
type: string
maxLength: 512
provider:
type: string
maxLength: 32
description: 推送服务提供方;为空时服务端按 platform 默认 android=fcm、ios=apns。
platform:
type: string
enum:
- android
- ios
app_version:
type: string
maxLength: 64
language:
type: string
maxLength: 32
timezone:
type: string
maxLength: 64
BindPushTokenResponse:
type: object
properties:
bound:
type: boolean
updated_at_ms:
type: integer
format: int64
DeletePushTokenRequest:
type: object
required:
- meta
- user_id
- device_id
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id:
type: integer
format: int64
device_id:
type: string
maxLength: 128
push_token:
type: string
maxLength: 512
description: 可选;为空时删除该设备当前 active token。
DeletePushTokenResponse:
type: object
properties:
deleted:
type: boolean
updated_at_ms:
type: integer
format: int64
ChangeUserCountryRequest:
type: object
properties:

View File

@ -0,0 +1,493 @@
# Voice Room Client API Flow
本文定义用户进入语音房后的客户端接口调用顺序、触发条件和服务边界。本文只描述 App 侧要调用的 gateway HTTP 接口、腾讯云 IM/RTC SDK 动作,以及不能由客户端直接调用的内部守卫接口。
## Scope
| Area | Owner | Rule |
| --- | --- | --- |
| HTTP 入口 | `gateway-service` | 鉴权、profile gate、协议转换、腾讯 IM/RTC token 签发 |
| 房间业务状态 | `room-service` | Room Cell、presence、麦位、房管、snapshot、outbox |
| 房间长连接和公屏 | 腾讯云 IM SDK | 客户端登录、房间群、公屏、系统消息接收 |
| 真实音频频道 | 腾讯云 RTC SDK | 进 RTC 房、切换 audience/anchor、发音频 |
| 用户主数据 | `user-service` | 登录、资料完成状态、用户区域、push token |
关键边界:
- 客户端进入语音房必须先 `JoinRoom` 写入 room-service 业务 presence再进 IM 群和 RTC 房。
- `room-service` 不保存客户端 socket也不保存腾讯云 RTC 连接态。
- 腾讯云 IM 群成员关系不是业务 presence是否允许进群、发言、签 RTC token 仍以 room-service 守卫为准。
- gateway 只使用 access token 中的 `user_id` 作为 actor客户端不能自报 `actor_user_id``owner_user_id``host_user_id`
- 所有 `/api/v1` 业务响应使用 `{code,message,request_id,data}` envelope。
## Minimal Enter Room Flow
```mermaid
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant IM as Tencent IM SDK
participant RTC as Tencent RTC SDK
C->>G: POST /api/v1/rooms/join
G->>R: JoinRoom(actor_user_id from token)
R-->>G: room snapshot + user presence
G-->>C: room snapshot
C->>G: GET /api/v1/im/usersig
G-->>C: IM user_sig
C->>IM: login(user_id, user_sig)
C->>IM: joinGroup(room_id)
C->>G: POST /api/v1/rtc/token
G->>R: VerifyRoomPresence(room_id, user_id)
G-->>C: RTC user_sig, str_room_id, role=audience
C->>RTC: enterRoom(strRoomId, audience)
loop while room page is active
C->>G: POST /api/v1/rooms/heartbeat
end
```
### Required Order
| Step | Interface | Required | Reason |
| --- | --- | --- | --- |
| 1 | `POST /api/v1/rooms/join` | yes | 建立业务 presence后续 IM/RTC 守卫依赖它 |
| 2 | `GET /api/v1/im/usersig` | yes | 登录腾讯云 IM接收房间群消息和系统事件 |
| 3 | Tencent IM SDK `login` + `joinGroup` | yes | 本仓库不承载房间长连接 |
| 4 | `POST /api/v1/rtc/token` | yes for voice room audio | 签发 TRTC 票据;当前返回 `role=audience` |
| 5 | Tencent RTC SDK `enterRoom` | yes for voice room audio | 进入真实音频频道旁听 |
| 6 | `POST /api/v1/rooms/heartbeat` | yes | 刷新业务 presence避免 stale timeout |
如果产品允许“只看公屏不听语音”,第 4、5 步可以延迟到用户打开音频时。但语音房默认应进入 RTC audience以保证打开房间即可听到声音。
## Core Interfaces
### Join Room
```http
POST /api/v1/rooms/join
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_join_<room_id>_<user_id>_<nonce>",
"role": "audience"
}
```
Response `data`:
```json
{
"result": {
"applied": true,
"room_version": 2,
"server_time_ms": 1778000000000
},
"user": {
"user_id": 10001,
"role": "audience",
"joined_at_ms": 1778000000000,
"last_seen_at_ms": 1778000000000
},
"room": {}
}
```
Rules:
- `room_id` 必须来自房间列表、分享或恢复接口,不能由客户端拼业务含义。
- `command_id` 用于房间命令幂等;客户端重试同一次进房必须复用同一个值。
- `role` 只作为展示默认值,不能通过自报 `admin/host/owner` 获得权限。
- JoinRoom 成功后才能登录 IM 进群和获取 RTC token。
### IM UserSig
```http
GET /api/v1/im/usersig
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"sdk_app_id": 20036101,
"user_id": "10001",
"user_sig": "xxx",
"expire_at_ms": 1778086400000
}
```
Rules:
- 当前 gateway 路由是 `GET /api/v1/im/usersig`
- `user_id` 是内部不可变长 `user_id` 的十进制字符串,必须直接作为腾讯云 IM identifier。
- UserSig 只用于腾讯云 IM SDK 登录,不用于本仓库鉴权。
- 未完成资料用户会被 profile gate 拒绝。
Client SDK action:
```text
Tencent IM SDK login(user_id, user_sig)
Tencent IM SDK joinGroup(room_id)
```
### RTC Token
```http
POST /api/v1/rtc/token
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx"
}
```
Response `data`:
```json
{
"sdk_app_id": 20036101,
"user_id": "10001",
"rtc_user_id": "10001",
"user_sig": "xxx",
"expire_at_ms": 1778007200000,
"room_id": "lalu_xxx",
"rtc_room_id": "lalu_xxx",
"rtc_room_id_type": "string",
"str_room_id": "lalu_xxx",
"app_scene": "voice_chat_room",
"role": "audience"
}
```
Rules:
- gateway 会先调用 room-service `VerifyRoomPresence`,用户不在房间、被踢、被 ban 时不会签发 RTC token。
- 当前只支持 TRTC `strRoomId`,客户端不能把 `room_id` 转为数字 `roomId`
- 当前 token 返回 `role=audience`;上麦成功后由客户端切 `anchor` 并确认发流。
- token 过期时重新调用该接口,不使用 refresh token。
Client SDK action:
```text
Tencent RTC SDK enterRoom(strRoomId=resp.str_room_id, userId=resp.rtc_user_id, role=audience)
```
### Heartbeat
```http
POST /api/v1/rooms/heartbeat
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_heartbeat_<room_id>_<user_id>_<tick>"
}
```
Rules:
- 只刷新已经存在的业务 presence不能替代 JoinRoom。
- 用户已经离房、被踢或房间关闭时,客户端应停止心跳并清理本地房间态。
- 心跳间隔由客户端配置控制,但必须小于 room-service 的 presence stale 窗口。
### Leave Room
```http
POST /api/v1/rooms/leave
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_leave_<room_id>_<user_id>_<nonce>"
}
```
Rules:
- 主动关闭房间页、切换账号、被客户端确认退出时调用。
- 如果用户在麦上LeaveRoom 会释放麦位。
- LeaveRoom 后客户端必须停止 heartbeat、退出 RTC 房、退出或忽略 IM 房间群消息。
## Mic Flow
### Mic Up
```http
POST /api/v1/rooms/mic/up
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_mic_up_<room_id>_<user_id>_<nonce>",
"seat_no": 1
}
```
Response `data`:
```json
{
"result": {},
"seat_no": 1,
"mic_session_id": "mic_xxx",
"publish_deadline_ms": 1778000015000,
"room": {}
}
```
Client actions after success:
1. 保存 `mic_session_id``room.version``publish_deadline_ms`
2. 确保已有 RTC token没有或过期则调用 `POST /api/v1/rtc/token`
3. RTC SDK 切换 `anchor`
4. RTC SDK 调 `startLocalAudio`
5. SDK 本地发流成功后调用 `POST /api/v1/rooms/mic/publishing/confirm`
Rules:
- `MicUp` 成功只代表业务麦位占用成功,不代表 RTC 已经发布音频成功。
- 服务端状态先进入 `pending_publish`
- 超过 `publish_deadline_ms` 未确认room-service 会自动 MicDownreason 为 `publish_timeout`
### Confirm Mic Publishing
```http
POST /api/v1/rooms/mic/publishing/confirm
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_confirm_publish_<room_id>_<user_id>_<nonce>",
"target_user_id": 0,
"mic_session_id": "mic_xxx",
"room_version": 12,
"event_time_ms": 1778000001000,
"source": "client"
}
```
Rules:
- `target_user_id=0` 表示确认当前 actorRTC webhook 路径才需要显式 target。
- `mic_session_id` 必须等于当前麦位会话。
- `room_version``event_time_ms` 用于丢弃旧事件,避免旧 audience/leave 事件清理新会话。
- 成功后麦位 `publish_state=publishing`
### Mic Down
```http
POST /api/v1/rooms/mic/down
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_mic_down_<room_id>_<user_id>_<nonce>",
"target_user_id": 0,
"reason": "user_action"
}
```
Rules:
- 普通用户下自己麦时 `target_user_id` 可传 0 或自己 user_id。
- 房管下别人麦时传目标用户 `target_user_id`
- 客户端收到成功响应或房间系统消息后必须停止本地音频并切回 `audience`
### Change Mic Seat
```http
POST /api/v1/rooms/mic/change
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_change_mic_<room_id>_<actor_user_id>_<nonce>",
"target_user_id": 10002,
"seat_no": 2
}
```
Rules:
- 换麦位只更新业务麦位和 UI不需要重新进入 RTC。
- 如果 target 当前在发流,客户端继续保持 RTC 上行。
## Room Management APIs
这些接口只允许有权限的 owner/host/admin 调用gateway 不判断权限,最终由 room-service Room Cell 判断。
| Interface | Body | Result |
| --- | --- | --- |
| `POST /api/v1/rooms/mic/lock` | `room_id, command_id, seat_no, locked` | 锁/解锁麦位 |
| `POST /api/v1/rooms/chat/enabled` | `room_id, command_id, enabled` | 开/关公屏,影响发言守卫 |
| `POST /api/v1/rooms/admin/set` | `room_id, command_id, target_user_id, is_admin` | 设置或取消房管 |
| `POST /api/v1/rooms/host/transfer` | `room_id, command_id, target_user_id` | 转移主持人 |
| `POST /api/v1/rooms/user/mute` | `room_id, command_id, target_user_id, muted, reason` | 禁言或解禁 |
| `POST /api/v1/rooms/user/kick` | `room_id, command_id, target_user_id, reason` | 踢人并 ban释放 presence 和麦位 |
| `POST /api/v1/rooms/user/unban` | `room_id, command_id, target_user_id, reason` | 解除房间 ban |
Management event handling:
- 管理动作成功后room-service 写 command log、snapshot 和 outbox。
- 客户端应通过腾讯云 IM 房间系统消息更新 UI。
- 如果本地 UI 状态和系统消息冲突,以最新 room snapshot 和 `room_version` 为准。
## Gift And Interaction
```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
Content-Type: application/json
```
Request:
```json
{
"room_id": "lalu_xxx",
"command_id": "cmd_send_gift_<room_id>_<user_id>_<nonce>",
"target_user_id": 10002,
"gift_id": "rose",
"gift_count": 1
}
```
Rules:
- `SendGift` 必须先由 wallet-service 扣费成功,再落房间表现。
- 房间表现、热度、本地榜通过 room snapshot 和房间系统消息同步。
- 公屏文本消息不走 gateway HTTP客户端使用腾讯云 IM 群消息。
## Restore And Reconnect
### Current Room
```http
GET /api/v1/rooms/current
Authorization: Bearer <access_token>
```
Response `data`:
```json
{
"has_current_room": true,
"room_id": "lalu_xxx",
"room_version": 12,
"role": "audience",
"mic_session_id": "",
"publish_state": "",
"need_join_im_group": true,
"need_rtc_token": false,
"server_time_ms": 1778000000000
}
```
Client restore rules:
| Response | Client Action |
| --- | --- |
| `has_current_room=false` | 清理本地房间态,不展示恢复入口 |
| `need_join_im_group=true` | 调 `GET /api/v1/im/usersig`,重新登录 IM 并 join group |
| `need_rtc_token=true` | 调 `POST /api/v1/rtc/token`,重新进入 RTC |
| `publish_state=pending_publish` | 重新执行 SDK 发流并确认当前 `mic_session_id` |
| `publish_state=publishing` | 确认本地 RTC 上行是否仍存在;不确定时重新发流并确认 |
`rooms/current` 是只读恢复探测,不会隐式 JoinRoom不刷新 heartbeat也不改变 Room Cell。
### Reconnect Order
1. access token 有效时先调 `GET /api/v1/rooms/current`
2. 如果 `has_current_room=false`,回房间列表或主界面。
3. 如果 `has_current_room=true`,先恢复 IM再恢复 RTC。
4. 如果用户原本在麦上,必须使用返回的 `mic_session_id` 继续确认或等待服务端超时下麦。
5. 恢复成功后恢复 `POST /api/v1/rooms/heartbeat` 定时器。
## Server Callback Boundaries
客户端不要直接调用这些接口或内部 RPC
| Interface | Caller | Purpose |
| --- | --- | --- |
| `POST /api/v1/tencent-im/callback` | Tencent IM | 入群、发言等服务端回调入口 |
| `POST /api/v1/tencent-rtc/callback` | Tencent RTC | RTC 音频事件回调入口 |
| `room-service.VerifyRoomPresence` | gateway / callback | 判断用户是否仍在房间、是否被 ban |
| `room-service.CheckSpeakPermission` | gateway / callback | 判断用户是否允许发公屏 |
客户端只消费这些回调产生的房间系统消息和最新 snapshot不直接操作内部守卫。
## Error Handling
| Error | Meaning | Client Action |
| --- | --- | --- |
| `UNAUTHORIZED` / `AUTH_REQUIRED` | access token 无效 | refresh 成功后重试refresh 失败回登录 |
| `PROFILE_REQUIRED` | 资料未完成 | 跳资料补全页,不能进入房间 |
| `NOT_FOUND` | 房间或用户事实不存在 | 清本地目标房间,回列表 |
| `ROOM_CLOSED` | 房间已关闭 | 清房间态,回列表 |
| `PERMISSION_DENIED` | 被踢、被 ban、无权限 | 停止对应操作,按系统消息更新 UI |
| `CONFLICT` | 麦位占用、命令冲突、状态冲突 | 拉最新 snapshot 或等待房间系统消息修正 |
| `UPSTREAM_ERROR` | 内部服务或腾讯云依赖异常 | 保留本地登录态,展示重试 |
不要因为普通网络失败或 5xx 清空登录态。只有 refresh 明确失败、session revoked 或用户主动 logout 才清 token。
## Room Snapshot Sync
当前客户端拿完整房间状态的来源:
- `JoinRoom` 响应里的 room snapshot。
- 房间命令响应里的 room snapshot。
- 腾讯云 IM 房间系统消息。
- `rooms/current` 的恢复摘要。
- `GET /api/v1/rooms/snapshot?room_id=<room_id>` 主动同步接口。
```http
GET /api/v1/rooms/snapshot?room_id=<room_id>
```
该接口只读 Room Cell/snapshot不刷新 presence不隐式 JoinRoom不替代 heartbeat。gateway 使用 access token user_id 作为 viewerroom-service 要求 viewer 仍在该房间;未进房、被踢或被 ban 的用户不能读取完整在线用户和麦位快照。

View File

@ -81,6 +81,19 @@ const (
EventAlreadyConsumed Code = "EVENT_ALREADY_CONSUMED"
// RewardPending 表示奖励仍在处理中,不能重复结算。
RewardPending Code = "REWARD_PENDING"
// InvalidSection 表示 App 消息分区不属于 system/activity。
InvalidSection Code = "INVALID_SECTION"
// MessageNotFound 表示消息不存在、已删除、已过期、已撤回或不属于当前用户。
MessageNotFound Code = "MESSAGE_NOT_FOUND"
// MessageRecalled 表示消息已撤回,不能继续展示或操作。
MessageRecalled Code = "MESSAGE_RECALLED"
// PageTokenInvalid 表示分页游标不能解析或不属于当前查询。
PageTokenInvalid Code = "PAGE_TOKEN_INVALID"
// RequestConflict 表示同 command_id 的 fanout 请求 payload 冲突。
RequestConflict Code = "REQUEST_CONFLICT"
// ProducerEventConflict 表示同 producer_event_id 的入箱请求 payload 冲突。
ProducerEventConflict Code = "PRODUCER_EVENT_CONFLICT"
)
// Error 是 domain/service 层统一返回的业务错误。

View File

@ -14,11 +14,11 @@ const errorDomain = "hyapp"
// gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。
func GRPCCode(code Code) codes.Code {
switch code {
case InvalidArgument, DisplayUserIDInvalid:
case InvalidArgument, DisplayUserIDInvalid, InvalidSection, PageTokenInvalid:
return codes.InvalidArgument
case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound:
case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound, MessageNotFound, MessageRecalled:
return codes.NotFound
case Conflict, RoomClosed, RegionCountryConflict, RegionDisabled:
case Conflict, RoomClosed, RegionCountryConflict, RegionDisabled, RequestConflict, ProducerEventConflict:
return codes.FailedPrecondition
case Unauthorized, AuthFailed, SessionExpired, SessionRevoked:
return codes.Unauthenticated

View File

@ -2,7 +2,16 @@ service_name: activity-service
node_id: activity-docker
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "user-service:13005"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -2,7 +2,16 @@ service_name: activity-service
node_id: activity-tencent
grpc_addr: ":13006"
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}"
user_service_addr: "user-service.internal:13005"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -2,7 +2,16 @@ service_name: activity-service
node_id: activity-local
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "127.0.0.1:13005"
mysql_auto_migrate: false
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100
message_inbox:
unread_cache_ttl: "60s"
fanout_worker:
enabled: true
poll_interval: "1s"
batch_size: 500
lock_ttl: "30s"
max_retry: 8

View File

@ -41,3 +41,87 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
PRIMARY KEY (app_code, outbox_id),
KEY idx_activity_outbox_status_retry (app_code, status, next_retry_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS message_templates (
app_code VARCHAR(32) NOT NULL,
template_id VARCHAR(96) NOT NULL,
template_version VARCHAR(64) NOT NULL,
message_type VARCHAR(32) NOT NULL,
locale VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
created_by VARCHAR(64) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, template_id, template_version),
KEY idx_message_templates_type_status (app_code, message_type, status),
KEY idx_message_templates_template_status (app_code, template_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_inbox_messages (
app_code VARCHAR(32) NOT NULL,
inbox_message_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
message_type VARCHAR(32) NOT NULL,
producer VARCHAR(64) NOT NULL,
producer_event_id VARCHAR(128) NOT NULL,
producer_event_type VARCHAR(96) NOT NULL DEFAULT '',
aggregate_type VARCHAR(64) NOT NULL DEFAULT '',
aggregate_id VARCHAR(128) NOT NULL DEFAULT '',
template_id VARCHAR(96) NOT NULL DEFAULT '',
template_version VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(512) NOT NULL,
body VARCHAR(2048) NOT NULL DEFAULT '',
icon_url VARCHAR(512) NOT NULL DEFAULT '',
image_url VARCHAR(512) NOT NULL DEFAULT '',
action_type VARCHAR(64) NOT NULL DEFAULT '',
action_param VARCHAR(1024) NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
read_at_ms BIGINT NULL,
deleted_at_ms BIGINT NULL,
sent_at_ms BIGINT NOT NULL,
expire_at_ms BIGINT NULL,
metadata_json JSON NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, inbox_message_id),
UNIQUE KEY uk_user_inbox_producer_event (app_code, user_id, producer, producer_event_id),
KEY idx_user_inbox_list (app_code, user_id, message_type, deleted_at_ms, status, sent_at_ms, inbox_message_id),
KEY idx_user_inbox_unread (app_code, user_id, message_type, read_at_ms, deleted_at_ms, status, expire_at_ms),
KEY idx_user_inbox_aggregate (app_code, user_id, aggregate_type, aggregate_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS message_fanout_jobs (
app_code VARCHAR(32) NOT NULL,
job_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
message_type VARCHAR(32) NOT NULL,
target_scope VARCHAR(64) NOT NULL,
target_filter_json JSON NOT NULL,
template_snapshot_json JSON NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
cursor_user_id BIGINT NOT NULL DEFAULT 0,
total_count BIGINT NOT NULL DEFAULT 0,
success_count BIGINT NOT NULL DEFAULT 0,
failure_count BIGINT NOT NULL DEFAULT 0,
batch_size INT NOT NULL DEFAULT 500,
next_run_at_ms BIGINT NOT NULL DEFAULT 0,
locked_by VARCHAR(96) NOT NULL DEFAULT '',
locked_until_ms BIGINT NOT NULL DEFAULT 0,
error_message VARCHAR(512) NOT NULL DEFAULT '',
created_by VARCHAR(64) NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, job_id),
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -8,23 +8,32 @@ import (
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/logx"
"hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config"
activityservice "hyapp/services/activity-service/internal/service/activity"
messageservice "hyapp/services/activity-service/internal/service/message"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
grpcserver "hyapp/services/activity-service/internal/transport/grpc"
)
// App 装配 activity-service gRPC 入口和底座依赖。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
mysqlRepo *mysqlstorage.Repository
userConn *grpc.ClientConn
message *messageservice.Service
cfg config.Config
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
closeOnce sync.Once
}
// New 初始化 activity-service 应用。
@ -43,24 +52,57 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close()
return nil, err
}
userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = listener.Close()
_ = repository.Close()
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(client.NewGRPCUserTargetSource(userConn)))
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(messageSvc))
health := grpchealth.NewServingChecker("activity-service")
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
server: server,
listener: listener,
health: health,
mysqlRepo: repository,
userConn: userConn,
message: messageSvc,
cfg: cfg,
workerCtx: workerCtx,
workerCancel: workerCancel,
}, nil
}
// Run 启动 gRPC 服务。
func (a *App) Run() error {
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
}()
if a.cfg.MessageInbox.FanoutWorker.Enabled {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.message.RunFanoutWorker(a.workerCtx, messageservice.FanoutWorkerOptions{
WorkerID: a.cfg.NodeID,
PollInterval: parseDurationOrZero(a.cfg.MessageInbox.FanoutWorker.PollInterval),
LockTTL: parseDurationOrZero(a.cfg.MessageInbox.FanoutWorker.LockTTL),
BatchSize: a.cfg.MessageInbox.FanoutWorker.BatchSize,
MaxRetry: a.cfg.MessageInbox.FanoutWorker.MaxRetry,
})
}()
}
err := a.server.Serve(a.listener)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
@ -74,10 +116,25 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.server.GracefulStop()
if a.userConn != nil {
_ = a.userConn.Close()
}
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
_ = a.mysqlRepo.Close()
}
})
}
func parseDurationOrZero(raw string) time.Duration {
duration, err := time.ParseDuration(raw)
if err != nil {
return 0
}
return duration
}

View File

@ -0,0 +1,40 @@
package client
import (
"context"
"google.golang.org/grpc"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
messageservice "hyapp/services/activity-service/internal/service/message"
)
// GRPCUserTargetSource reads fanout target user IDs from user-service instead of activity owning user data.
type GRPCUserTargetSource struct {
client userv1.UserServiceClient
}
// NewGRPCUserTargetSource creates a user-service backed target source for message fanout workers.
func NewGRPCUserTargetSource(conn *grpc.ClientConn) *GRPCUserTargetSource {
return &GRPCUserTargetSource{client: userv1.NewUserServiceClient(conn)}
}
// ListFanoutUserIDs returns a stable user_id cursor page for region/country/all-active fanout scopes.
func (s *GRPCUserTargetSource) ListFanoutUserIDs(ctx context.Context, query messageservice.TargetQuery) ([]int64, int64, bool, error) {
resp, err := s.client.ListUserIDs(ctx, &userv1.ListUserIDsRequest{
Meta: &userv1.RequestMeta{
RequestId: "message-fanout-worker",
Caller: "activity-service",
AppCode: appcode.FromContext(ctx),
},
TargetScope: query.TargetScope,
CursorUserId: query.CursorUserID,
PageSize: int32(query.PageSize),
RegionId: query.RegionID,
Country: query.Country,
})
if err != nil {
return nil, query.CursorUserID, false, err
}
return resp.GetUserIds(), resp.GetNextCursorUserId(), resp.GetDone(), nil
}

View File

@ -1,6 +1,10 @@
package config
import "hyapp/pkg/configx"
import (
"strings"
"hyapp/pkg/configx"
)
// Config 描述 activity-service 启动配置。
type Config struct {
@ -9,9 +13,12 @@ type Config struct {
GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN 是活动状态、事件消费幂等和 outbox 的必需存储连接串。
MySQLDSN string `yaml:"mysql_dsn"`
// UserServiceAddr 是 fanout worker 读取目标用户游标的内部 gRPC 地址。
UserServiceAddr string `yaml:"user_service_addr"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"`
MessageInbox MessageInboxConfig `yaml:"message_inbox"`
}
// ConsumerConfig 保存 room outbox 消费底座配置。
@ -20,6 +27,21 @@ type ConsumerConfig struct {
BatchSize int `yaml:"batch_size"`
}
// MessageInboxConfig 保存 App system/activity inbox 的缓存和 fanout worker 参数。
type MessageInboxConfig struct {
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
FanoutWorker MessageFanoutWorkerConfig `yaml:"fanout_worker"`
}
// MessageFanoutWorkerConfig 描述后台消息 fanout worker 的 claim、批量和重试策略。
type MessageFanoutWorkerConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval string `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
LockTTL string `yaml:"lock_ttl"`
MaxRetry int `yaml:"max_retry"`
}
// Default 返回本地默认配置。
func Default() Config {
return Config{
@ -27,11 +49,22 @@ func Default() Config {
NodeID: "activity-local",
GRPCAddr: ":13006",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
UserServiceAddr: "127.0.0.1:13005",
MySQLAutoMigrate: false,
Consumer: ConsumerConfig{
RoomOutboxPollIntervalMs: 1000,
BatchSize: 100,
},
MessageInbox: MessageInboxConfig{
UnreadCacheTTL: "60s",
FanoutWorker: MessageFanoutWorkerConfig{
Enabled: true,
PollInterval: "1s",
BatchSize: 500,
LockTTL: "30s",
MaxRetry: 8,
},
},
}
}
@ -45,6 +78,9 @@ func Load(path string) (Config, error) {
if err := configx.LoadYAML(path, &cfg); err != nil {
return Config{}, err
}
if strings.TrimSpace(cfg.UserServiceAddr) == "" {
cfg.UserServiceAddr = Default().UserServiceAddr
}
return cfg, nil
}

View File

@ -0,0 +1,165 @@
// Package message defines the App message tab inbox domain owned by activity-service.
package message
const (
// SectionUser is sourced from Tencent IM SDK and never persisted by this service.
SectionUser = "user"
// SectionSystem stores account, wallet, room and application notifications.
SectionSystem = "system"
// SectionActivity stores activity, reward and operational campaign notifications.
SectionActivity = "activity"
SourceTencentIMSDK = "tencent_im_sdk"
SourceBackend = "backend"
StatusVisible = "visible"
StatusRecalled = "recalled"
StatusHidden = "hidden"
StatusExpired = "expired"
FanoutStatusPending = "pending"
FanoutStatusRunning = "running"
FanoutStatusRetrying = "retrying"
FanoutStatusFailed = "failed"
FanoutStatusComplete = "completed"
TargetScopeSingleUser = "single_user"
TargetScopeUserIDs = "user_ids"
TargetScopeRegion = "region"
TargetScopeCountry = "country"
TargetScopeAllActiveUsers = "all_active_users"
)
// TabSection is the message tab summary returned to App clients.
type TabSection struct {
Section string
Title string
UnreadCount int64
Source string
}
// InboxMessage is the user-visible snapshot stored in user_inbox_messages.
type InboxMessage struct {
AppCode string
MessageID string
UserID int64
MessageType string
Producer string
ProducerEventID string
ProducerEventType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
Status string
ReadAtMS int64
DeletedAtMS int64
SentAtMS int64
ExpireAtMS int64
MetadataJSON string
CreatedAtMS int64
UpdatedAtMS int64
}
// Read reports whether the user has marked the message as read.
func (m InboxMessage) Read() bool {
return m.ReadAtMS > 0
}
// MessageTemplate is an active server-owned content template snapshot source.
type MessageTemplate struct {
AppCode string
TemplateID string
TemplateVersion string
MessageType string
Locale string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Status string
CreatedBy string
CreatedAtMS int64
UpdatedAtMS int64
}
// ListQuery is the stable cursor query for system/activity inbox lists.
type ListQuery struct {
UserID int64
Section string
Limit int
CursorSentAtMS int64
CursorMessageID string
NowMS int64
}
// CreateInput carries a validated single-user inbox creation command.
type CreateInput struct {
TargetUserID int64
Producer string
ProducerEventID string
ProducerEventType string
MessageType string
AggregateType string
AggregateID string
TemplateID string
TemplateVersion string
Title string
Summary string
Body string
IconURL string
ImageURL string
ActionType string
ActionParam string
Priority int32
SentAtMS int64
ExpireAtMS int64
MetadataJSON string
}
// FanoutJob stores a background fanout command without executing user selection in the request path.
type FanoutJob struct {
AppCode string
JobID string
CommandID string
MessageType string
TargetScope string
TargetFilterJSON string
TemplateSnapshotJSON string
Status string
AttemptCount int32
CursorUserID int64
TotalCount int64
SuccessCount int64
FailureCount int64
BatchSize int32
NextRunAtMS int64
LockedBy string
LockedUntilMS int64
ErrorMessage string
CreatedBy string
CreatedAtMS int64
UpdatedAtMS int64
}
// CreateFanoutInput is the validated admin or producer command for fanout job creation.
type CreateFanoutInput struct {
CommandID string
MessageType string
TargetScope string
TargetFilterJSON string
TemplateSnapshotJSON string
BatchSize int32
CreatedBy string
}

View File

@ -0,0 +1,265 @@
package message
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
const (
defaultFanoutPollInterval = time.Second
defaultFanoutLockTTL = 30 * time.Second
defaultFanoutMaxRetry = 8
fanoutProducer = "message-fanout"
fanoutProducerEventType = "message_fanout"
)
// FanoutWorkerOptions carries runtime knobs for the durable fanout worker.
type FanoutWorkerOptions struct {
WorkerID string
PollInterval time.Duration
LockTTL time.Duration
BatchSize int
MaxRetry int
}
type fanoutTargetFilter struct {
TargetUserID int64 `json:"target_user_id"`
UserID int64 `json:"user_id"`
UserIDs []int64 `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
}
type fanoutTemplateSnapshot struct {
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON json.RawMessage `json:"metadata_json"`
}
// RunFanoutWorker continuously claims and processes fanout jobs until ctx is cancelled.
func (s *Service) RunFanoutWorker(ctx context.Context, options FanoutWorkerOptions) {
options = normalizeFanoutWorkerOptions(options, s.cfg.NodeID)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
processed, _ := s.ProcessNextFanoutJob(ctx, options)
delay := time.Duration(0)
if !processed {
delay = options.PollInterval
}
timer.Reset(delay)
}
}
}
// ProcessNextFanoutJob claims one job, processes one target page, then commits progress.
func (s *Service) ProcessNextFanoutJob(ctx context.Context, options FanoutWorkerOptions) (bool, error) {
if err := s.requireRepository(); err != nil {
return false, err
}
options = normalizeFanoutWorkerOptions(options, s.cfg.NodeID)
nowMS := s.now().UnixMilli()
job, claimed, err := s.repository.ClaimFanoutJob(ctx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.MaxRetry)
if err != nil || !claimed {
return claimed, err
}
jobCtx := appcode.WithContext(ctx, job.AppCode)
progress, processErr := s.processFanoutJobPage(jobCtx, job, options)
if processErr != nil {
failErr := s.repository.FailFanoutJob(jobCtx, job.JobID, options.WorkerID, nowMS, nowMS+options.PollInterval.Milliseconds(), options.MaxRetry, processErr.Error())
if failErr != nil {
return true, failErr
}
return true, processErr
}
if err := s.repository.FinishFanoutJobBatch(jobCtx, job.JobID, options.WorkerID, progress.nextCursorUserID, progress.totalDelta, progress.successDelta, progress.failureDelta, progress.done, nowMS, progress.errorMessage); err != nil {
return true, err
}
return true, nil
}
type fanoutProgress struct {
nextCursorUserID int64
totalDelta int64
successDelta int64
failureDelta int64
done bool
errorMessage string
}
func (s *Service) processFanoutJobPage(ctx context.Context, job messagedomain.FanoutJob, options FanoutWorkerOptions) (fanoutProgress, error) {
var target fanoutTargetFilter
if err := json.Unmarshal([]byte(job.TargetFilterJSON), &target); err != nil {
return fanoutProgress{}, xerr.New(xerr.InvalidArgument, "target_filter_json is invalid")
}
var snapshot fanoutTemplateSnapshot
if err := json.Unmarshal([]byte(job.TemplateSnapshotJSON), &snapshot); err != nil {
return fanoutProgress{}, xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
}
pageSize := int(normalizeBatchSize(firstPositiveInt32(job.BatchSize, int32(options.BatchSize))))
userIDs, nextCursor, done, err := s.fanoutTargetPage(ctx, job, target, pageSize)
if err != nil {
return fanoutProgress{}, err
}
progress := fanoutProgress{nextCursorUserID: nextCursor, totalDelta: int64(len(userIDs)), done: done}
for _, userID := range userIDs {
_, _, err := s.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: userID,
Producer: fanoutProducer,
ProducerEventID: fmt.Sprintf("fanout:%s:%d", job.JobID, userID),
ProducerEventType: firstNonEmpty(snapshot.ProducerEventType, fanoutProducerEventType),
MessageType: job.MessageType,
AggregateType: snapshot.AggregateType,
AggregateID: snapshot.AggregateID,
TemplateID: snapshot.TemplateID,
TemplateVersion: snapshot.TemplateVersion,
Title: snapshot.Title,
Summary: snapshot.Summary,
Body: snapshot.Body,
IconURL: snapshot.IconURL,
ImageURL: snapshot.ImageURL,
ActionType: snapshot.ActionType,
ActionParam: snapshot.ActionParam,
Priority: snapshot.Priority,
SentAtMS: snapshot.SentAtMS,
ExpireAtMS: snapshot.ExpireAtMS,
MetadataJSON: rawJSONToString(snapshot.MetadataJSON),
})
if err != nil {
return fanoutProgress{}, err
}
progress.successDelta++
}
return progress, nil
}
func (s *Service) fanoutTargetPage(ctx context.Context, job messagedomain.FanoutJob, target fanoutTargetFilter, pageSize int) ([]int64, int64, bool, error) {
switch job.TargetScope {
case messagedomain.TargetScopeSingleUser:
userID := firstPositive(target.TargetUserID, target.UserID)
if userID <= 0 {
return nil, job.CursorUserID, false, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
if job.CursorUserID >= userID {
return nil, job.CursorUserID, true, nil
}
return []int64{userID}, userID, true, nil
case messagedomain.TargetScopeUserIDs:
return userIDsTargetPage(target.UserIDs, job.CursorUserID, pageSize)
case messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers:
if s.targetSource == nil {
return nil, job.CursorUserID, false, xerr.New(xerr.Unavailable, "fanout target source is not configured")
}
return s.targetSource.ListFanoutUserIDs(ctx, TargetQuery{
TargetScope: job.TargetScope,
CursorUserID: job.CursorUserID,
PageSize: pageSize,
RegionID: target.RegionID,
Country: target.Country,
})
default:
return nil, job.CursorUserID, false, xerr.New(xerr.InvalidArgument, "target_scope is invalid")
}
}
func userIDsTargetPage(userIDs []int64, cursorUserID int64, pageSize int) ([]int64, int64, bool, error) {
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]bool, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
return nil, cursorUserID, false, xerr.New(xerr.InvalidArgument, "user_ids contains invalid value")
}
if !seen[userID] {
seen[userID] = true
unique = append(unique, userID)
}
}
sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] })
start := sort.Search(len(unique), func(i int) bool { return unique[i] > cursorUserID })
if start >= len(unique) {
return nil, cursorUserID, true, nil
}
end := start + pageSize
if end > len(unique) {
end = len(unique)
}
page := append([]int64(nil), unique[start:end]...)
return page, page[len(page)-1], end >= len(unique), nil
}
func normalizeFanoutWorkerOptions(options FanoutWorkerOptions, defaultWorkerID string) FanoutWorkerOptions {
options.WorkerID = strings.TrimSpace(options.WorkerID)
if options.WorkerID == "" {
options.WorkerID = firstNonEmpty(defaultWorkerID, "activity-fanout-worker")
}
if options.PollInterval <= 0 {
options.PollInterval = defaultFanoutPollInterval
}
if options.LockTTL <= 0 {
options.LockTTL = defaultFanoutLockTTL
}
if options.BatchSize <= 0 {
options.BatchSize = defaultBatchSize
}
if options.MaxRetry <= 0 {
options.MaxRetry = defaultFanoutMaxRetry
}
return options
}
func rawJSONToString(raw json.RawMessage) string {
raw = json.RawMessage([]byte(strings.TrimSpace(string(raw))))
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return strings.TrimSpace(text)
}
return string(raw)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func firstPositiveInt32(values ...int32) int32 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}

View File

@ -0,0 +1,549 @@
// Package message implements the App system/activity message inbox owner.
package message
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"time"
"unicode/utf8"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
const (
defaultPageSize = 20
maxPageSize = 100
defaultBatchSize = 500
maxBatchSize = 5000
)
// Repository is the storage boundary for MySQL-backed inbox facts.
type Repository interface {
GetActiveMessageTemplate(ctx context.Context, templateID string, templateVersion string, messageType string) (messagedomain.MessageTemplate, bool, error)
SaveInboxMessage(ctx context.Context, message messagedomain.InboxMessage) (bool, messagedomain.InboxMessage, error)
CountUnreadInboxMessages(ctx context.Context, userID int64, section string, nowMS int64) (int64, error)
ListInboxMessages(ctx context.Context, query messagedomain.ListQuery) ([]messagedomain.InboxMessage, error)
MarkInboxMessageRead(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, error)
MarkInboxSectionRead(ctx context.Context, userID int64, section string, nowMS int64) (int64, error)
DeleteInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) error
SaveFanoutJob(ctx context.Context, job messagedomain.FanoutJob) (bool, messagedomain.FanoutJob, error)
ClaimFanoutJob(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, maxRetry int) (messagedomain.FanoutJob, bool, error)
FinishFanoutJobBatch(ctx context.Context, jobID string, workerID string, cursorUserID int64, totalDelta int64, successDelta int64, failureDelta int64, done bool, nowMS int64, errorMessage string) error
FailFanoutJob(ctx context.Context, jobID string, workerID string, nowMS int64, nextRunAtMS int64, maxRetry int, errorMessage string) error
}
// Config keeps service-level knobs that do not belong in transport code.
type Config struct {
NodeID string
}
// TargetQuery describes the user-service cursor query needed by fanout worker scopes.
type TargetQuery struct {
TargetScope string
CursorUserID int64
PageSize int
RegionID int64
Country string
}
// TargetSource hides user-service gRPC behind a minimal page query.
type TargetSource interface {
ListFanoutUserIDs(ctx context.Context, query TargetQuery) ([]int64, int64, bool, error)
}
// Option adjusts message service dependencies.
type Option func(*Service)
// Service owns system/activity inbox business rules.
type Service struct {
cfg Config
repository Repository
targetSource TargetSource
now func() time.Time
}
// New creates a message inbox service using MySQL as source of truth.
func New(cfg Config, repository Repository, options ...Option) *Service {
service := &Service{cfg: cfg, repository: repository, now: time.Now}
for _, option := range options {
option(service)
}
return service
}
// WithTargetSource connects fanout worker scopes to user-service without coupling to its storage.
func WithTargetSource(source TargetSource) Option {
return func(s *Service) {
s.targetSource = source
}
}
// SetClock injects deterministic time for tests.
func (s *Service) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
// ListMessageTabs returns the fixed App message tab summary.
func (s *Service) ListMessageTabs(ctx context.Context, userID int64) ([]messagedomain.TabSection, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
nowMS := s.now().UnixMilli()
systemUnread, err := s.repository.CountUnreadInboxMessages(ctx, userID, messagedomain.SectionSystem, nowMS)
if err != nil {
return nil, err
}
activityUnread, err := s.repository.CountUnreadInboxMessages(ctx, userID, messagedomain.SectionActivity, nowMS)
if err != nil {
return nil, err
}
return []messagedomain.TabSection{
{Section: messagedomain.SectionUser, Title: "用户", UnreadCount: 0, Source: messagedomain.SourceTencentIMSDK},
{Section: messagedomain.SectionSystem, Title: "系统", UnreadCount: systemUnread, Source: messagedomain.SourceBackend},
{Section: messagedomain.SectionActivity, Title: "活动", UnreadCount: activityUnread, Source: messagedomain.SourceBackend},
}, nil
}
// ListInboxMessages reads one cursor page for a backend-owned section.
func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section string, pageSize int32, pageToken string) ([]messagedomain.InboxMessage, string, error) {
if err := s.requireRepository(); err != nil {
return nil, "", err
}
if userID <= 0 {
return nil, "", xerr.New(xerr.InvalidArgument, "user_id is required")
}
section = normalizeSection(section)
if !backendSection(section) {
return nil, "", xerr.New(xerr.InvalidSection, "section is invalid")
}
limit := normalizePageSize(pageSize)
cursor, err := decodeCursor(section, pageToken)
if err != nil {
return nil, "", err
}
messages, err := s.repository.ListInboxMessages(ctx, messagedomain.ListQuery{
UserID: userID,
Section: section,
Limit: limit + 1,
CursorSentAtMS: cursor.SentAtMS,
CursorMessageID: cursor.MessageID,
NowMS: s.now().UnixMilli(),
})
if err != nil {
return nil, "", err
}
if len(messages) <= limit {
return messages, "", nil
}
page := messages[:limit]
next := encodeCursor(section, page[len(page)-1])
return page, next, nil
}
// MarkInboxMessageRead marks a single visible message read while preserving first read time.
func (s *Service) MarkInboxMessageRead(ctx context.Context, userID int64, messageID string) (messagedomain.InboxMessage, error) {
if err := s.requireRepository(); err != nil {
return messagedomain.InboxMessage{}, err
}
messageID = strings.TrimSpace(messageID)
if userID <= 0 || messageID == "" {
return messagedomain.InboxMessage{}, xerr.New(xerr.InvalidArgument, "user_id and message_id are required")
}
return s.repository.MarkInboxMessageRead(ctx, userID, messageID, s.now().UnixMilli())
}
// MarkInboxSectionRead marks all visible unread messages in a backend section.
func (s *Service) MarkInboxSectionRead(ctx context.Context, userID int64, section string) (int64, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
section = normalizeSection(section)
if userID <= 0 || !backendSection(section) {
return 0, xerr.New(xerr.InvalidSection, "section is invalid")
}
return s.repository.MarkInboxSectionRead(ctx, userID, section, s.now().UnixMilli())
}
// DeleteInboxMessage hides a visible message for the current user.
func (s *Service) DeleteInboxMessage(ctx context.Context, userID int64, messageID string) error {
if err := s.requireRepository(); err != nil {
return err
}
messageID = strings.TrimSpace(messageID)
if userID <= 0 || messageID == "" {
return xerr.New(xerr.InvalidArgument, "user_id and message_id are required")
}
return s.repository.DeleteInboxMessage(ctx, userID, messageID, s.now().UnixMilli())
}
// CreateInboxMessage materializes one producer event into a user inbox row.
func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.CreateInput) (messagedomain.InboxMessage, bool, error) {
if err := s.requireRepository(); err != nil {
return messagedomain.InboxMessage{}, false, err
}
input = normalizeCreateInput(input)
if err := s.fillTemplateSnapshot(ctx, &input); err != nil {
return messagedomain.InboxMessage{}, false, err
}
if err := validateCreateInput(input); err != nil {
return messagedomain.InboxMessage{}, false, err
}
nowMS := s.now().UnixMilli()
message := messagedomain.InboxMessage{
MessageID: idgen.New("msg"),
UserID: input.TargetUserID,
MessageType: input.MessageType,
Producer: input.Producer,
ProducerEventID: input.ProducerEventID,
ProducerEventType: input.ProducerEventType,
AggregateType: input.AggregateType,
AggregateID: input.AggregateID,
TemplateID: input.TemplateID,
TemplateVersion: input.TemplateVersion,
Title: input.Title,
Summary: input.Summary,
Body: input.Body,
IconURL: input.IconURL,
ImageURL: input.ImageURL,
ActionType: input.ActionType,
ActionParam: input.ActionParam,
Priority: input.Priority,
Status: messagedomain.StatusVisible,
SentAtMS: firstPositive(input.SentAtMS, nowMS),
ExpireAtMS: input.ExpireAtMS,
MetadataJSON: input.MetadataJSON,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
created, existing, err := s.repository.SaveInboxMessage(ctx, message)
if err != nil {
return messagedomain.InboxMessage{}, false, err
}
if !created {
if !sameInboxPayload(existing, message) {
return messagedomain.InboxMessage{}, false, xerr.New(xerr.ProducerEventConflict, "producer event payload conflicts with existing inbox message")
}
return existing, false, nil
}
return existing, true, nil
}
// CreateFanoutJob records an async fanout job and keeps large broadcasts off the request path.
func (s *Service) CreateFanoutJob(ctx context.Context, input messagedomain.CreateFanoutInput) (messagedomain.FanoutJob, bool, error) {
if err := s.requireRepository(); err != nil {
return messagedomain.FanoutJob{}, false, err
}
input = normalizeFanoutInput(input)
if err := validateFanoutInput(input); err != nil {
return messagedomain.FanoutJob{}, false, err
}
nowMS := s.now().UnixMilli()
job := messagedomain.FanoutJob{
JobID: idgen.New("mfan"),
CommandID: input.CommandID,
MessageType: input.MessageType,
TargetScope: input.TargetScope,
TargetFilterJSON: input.TargetFilterJSON,
TemplateSnapshotJSON: input.TemplateSnapshotJSON,
Status: messagedomain.FanoutStatusPending,
BatchSize: normalizeBatchSize(input.BatchSize),
CreatedBy: input.CreatedBy,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
created, existing, err := s.repository.SaveFanoutJob(ctx, job)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
if !created {
if !sameFanoutPayload(existing, job) {
return messagedomain.FanoutJob{}, false, xerr.New(xerr.RequestConflict, "fanout command payload conflicts with existing job")
}
return existing, false, nil
}
return existing, true, nil
}
func (s *Service) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "message repository is not configured")
}
return nil
}
func (s *Service) fillTemplateSnapshot(ctx context.Context, input *messagedomain.CreateInput) error {
if input.Title != "" || input.Summary != "" {
return nil
}
if input.TemplateID == "" || input.TemplateVersion == "" {
return nil
}
template, exists, err := s.repository.GetActiveMessageTemplate(ctx, input.TemplateID, input.TemplateVersion, input.MessageType)
if err != nil {
return err
}
if !exists {
return xerr.New(xerr.NotFound, "message template not found")
}
input.Title = template.Title
input.Summary = template.Summary
input.Body = template.Body
input.IconURL = template.IconURL
input.ImageURL = template.ImageURL
input.ActionType = template.ActionType
input.ActionParam = template.ActionParam
return nil
}
type pageCursor struct {
Section string `json:"section"`
SentAtMS int64 `json:"sent_at_ms"`
MessageID string `json:"message_id"`
}
func encodeCursor(section string, message messagedomain.InboxMessage) string {
payload, err := json.Marshal(pageCursor{Section: section, SentAtMS: message.SentAtMS, MessageID: message.MessageID})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeCursor(section string, encoded string) (pageCursor, error) {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return pageCursor{Section: section}, nil
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
var cursor pageCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
if cursor.Section != section || cursor.SentAtMS <= 0 || strings.TrimSpace(cursor.MessageID) == "" {
return pageCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
return cursor, nil
}
func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateInput {
input.Producer = strings.TrimSpace(input.Producer)
input.ProducerEventID = strings.TrimSpace(input.ProducerEventID)
input.ProducerEventType = strings.TrimSpace(input.ProducerEventType)
input.MessageType = normalizeSection(input.MessageType)
input.AggregateType = strings.TrimSpace(input.AggregateType)
input.AggregateID = strings.TrimSpace(input.AggregateID)
input.TemplateID = strings.TrimSpace(input.TemplateID)
input.TemplateVersion = strings.TrimSpace(input.TemplateVersion)
input.Title = strings.TrimSpace(input.Title)
input.Summary = strings.TrimSpace(input.Summary)
input.Body = strings.TrimSpace(input.Body)
input.IconURL = strings.TrimSpace(input.IconURL)
input.ImageURL = strings.TrimSpace(input.ImageURL)
input.ActionType = strings.TrimSpace(input.ActionType)
input.ActionParam = strings.TrimSpace(input.ActionParam)
input.MetadataJSON = strings.TrimSpace(input.MetadataJSON)
return input
}
func validateCreateInput(input messagedomain.CreateInput) error {
if input.TargetUserID <= 0 || input.Producer == "" || input.ProducerEventID == "" {
return xerr.New(xerr.InvalidArgument, "target_user_id, producer and producer_event_id are required")
}
if !backendSection(input.MessageType) {
return xerr.New(xerr.InvalidSection, "message_type is invalid")
}
for _, field := range []struct {
name string
value string
max int
}{
{"producer", input.Producer, 64},
{"producer_event_id", input.ProducerEventID, 128},
{"producer_event_type", input.ProducerEventType, 96},
{"aggregate_type", input.AggregateType, 64},
{"aggregate_id", input.AggregateID, 128},
{"template_id", input.TemplateID, 96},
{"template_version", input.TemplateVersion, 64},
{"title", input.Title, 160},
{"summary", input.Summary, 512},
{"body", input.Body, 2048},
{"icon_url", input.IconURL, 512},
{"image_url", input.ImageURL, 512},
{"action_type", input.ActionType, 64},
{"action_param", input.ActionParam, 1024},
} {
if utf8.RuneCountInString(field.value) > field.max {
return xerr.New(xerr.InvalidArgument, field.name+" is too long")
}
}
if input.Title == "" || input.Summary == "" {
return xerr.New(xerr.InvalidArgument, "title and summary are required")
}
if !allowedActionType(input.ActionType) {
return xerr.New(xerr.InvalidArgument, "action_type is invalid")
}
if input.ExpireAtMS > 0 && input.SentAtMS > 0 && input.ExpireAtMS <= input.SentAtMS {
return xerr.New(xerr.InvalidArgument, "expire_at_ms must be after sent_at_ms")
}
if input.MetadataJSON != "" && !json.Valid([]byte(input.MetadataJSON)) {
return xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
}
return nil
}
func normalizeFanoutInput(input messagedomain.CreateFanoutInput) messagedomain.CreateFanoutInput {
input.CommandID = strings.TrimSpace(input.CommandID)
input.MessageType = normalizeSection(input.MessageType)
input.TargetScope = strings.TrimSpace(input.TargetScope)
input.TargetFilterJSON = strings.TrimSpace(input.TargetFilterJSON)
input.TemplateSnapshotJSON = strings.TrimSpace(input.TemplateSnapshotJSON)
input.CreatedBy = strings.TrimSpace(input.CreatedBy)
return input
}
func validateFanoutInput(input messagedomain.CreateFanoutInput) error {
if input.CommandID == "" || input.TargetScope == "" || input.CreatedBy == "" {
return xerr.New(xerr.InvalidArgument, "command_id, target_scope and created_by are required")
}
if !backendSection(input.MessageType) {
return xerr.New(xerr.InvalidSection, "message_type is invalid")
}
if !allowedFanoutScope(input.TargetScope) {
return xerr.New(xerr.InvalidArgument, "target_scope is invalid")
}
if input.TargetFilterJSON == "" || !json.Valid([]byte(input.TargetFilterJSON)) {
return xerr.New(xerr.InvalidArgument, "target_filter_json is invalid")
}
if input.TemplateSnapshotJSON == "" || !json.Valid([]byte(input.TemplateSnapshotJSON)) {
return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
}
return nil
}
func normalizeSection(section string) string {
return strings.ToLower(strings.TrimSpace(section))
}
func backendSection(section string) bool {
return section == messagedomain.SectionSystem || section == messagedomain.SectionActivity
}
func normalizePageSize(value int32) int {
if value <= 0 {
return defaultPageSize
}
if value > maxPageSize {
return maxPageSize
}
return int(value)
}
func normalizeBatchSize(value int32) int32 {
if value <= 0 {
return defaultBatchSize
}
if value > maxBatchSize {
return maxBatchSize
}
return value
}
func allowedActionType(actionType string) bool {
switch actionType {
case "", "wallet_withdraw_detail", "host_application_detail", "activity_detail", "room_detail", "user_profile", "app_h5", "coin_seller_transfer_detail":
return true
default:
return false
}
}
func allowedFanoutScope(scope string) bool {
switch scope {
case messagedomain.TargetScopeSingleUser, messagedomain.TargetScopeUserIDs, messagedomain.TargetScopeRegion, messagedomain.TargetScopeCountry, messagedomain.TargetScopeAllActiveUsers:
return true
default:
return false
}
}
func firstPositive(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.InboxMessage) bool {
return existing.UserID == next.UserID &&
existing.MessageType == next.MessageType &&
existing.Producer == next.Producer &&
existing.ProducerEventID == next.ProducerEventID &&
existing.ProducerEventType == next.ProducerEventType &&
existing.AggregateType == next.AggregateType &&
existing.AggregateID == next.AggregateID &&
existing.TemplateID == next.TemplateID &&
existing.TemplateVersion == next.TemplateVersion &&
existing.Title == next.Title &&
existing.Summary == next.Summary &&
existing.Body == next.Body &&
existing.IconURL == next.IconURL &&
existing.ImageURL == next.ImageURL &&
existing.ActionType == next.ActionType &&
existing.ActionParam == next.ActionParam &&
existing.Priority == next.Priority &&
existing.ExpireAtMS == next.ExpireAtMS &&
sameJSONText(existing.MetadataJSON, next.MetadataJSON)
}
func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool {
return existing.CommandID == next.CommandID &&
existing.MessageType == next.MessageType &&
existing.TargetScope == next.TargetScope &&
sameJSONText(existing.TargetFilterJSON, next.TargetFilterJSON) &&
sameJSONText(existing.TemplateSnapshotJSON, next.TemplateSnapshotJSON) &&
existing.BatchSize == next.BatchSize &&
existing.CreatedBy == next.CreatedBy
}
func sameJSONText(left string, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" || left == "null" {
return right == "" || right == "null"
}
leftCanonical, leftOK := canonicalJSON(left)
rightCanonical, rightOK := canonicalJSON(right)
if leftOK && rightOK {
return leftCanonical == rightCanonical
}
return left == right
}
func canonicalJSON(value string) (string, bool) {
var parsed any
if err := json.Unmarshal([]byte(value), &parsed); err != nil {
return "", false
}
encoded, err := json.Marshal(parsed)
if err != nil {
return "", false
}
return string(encoded), true
}

View File

@ -0,0 +1,229 @@
package message_test
import (
"context"
"testing"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
messageservice "hyapp/services/activity-service/internal/service/message"
"hyapp/services/activity-service/internal/testutil/mysqltest"
)
func TestInboxMessageLifecycleUsesMySQLFacts(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1_800_000_000_000)
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository)
svc.SetClock(func() time.Time { return now })
first, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: 42,
Producer: "wallet-service",
ProducerEventID: "withdraw-approved-1",
ProducerEventType: "withdraw_approved",
MessageType: messagedomain.SectionSystem,
Title: "提现审核通过",
Summary: "你的提现申请已通过。",
ActionType: "wallet_withdraw_detail",
ActionParam: "withdrawal_id=wd_01",
SentAtMS: 1_800_000_000_000,
})
if err != nil || !created {
t.Fatalf("CreateInboxMessage failed: created=%v message=%+v err=%v", created, first, err)
}
second, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: 42,
Producer: "activity-service",
ProducerEventID: "reward-1",
ProducerEventType: "activity_reward",
MessageType: messagedomain.SectionActivity,
Title: "活动奖励到账",
Summary: "你的活动奖励已发放。",
ActionType: "activity_detail",
ActionParam: "activity_id=act_01",
SentAtMS: 1_800_000_000_100,
})
if err != nil || !created {
t.Fatalf("CreateInboxMessage activity failed: created=%v message=%+v err=%v", created, second, err)
}
if _, created, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: 42,
Producer: "wallet-service",
ProducerEventID: "withdraw-approved-1",
ProducerEventType: "withdraw_approved",
MessageType: messagedomain.SectionSystem,
Title: "提现审核通过",
Summary: "你的提现申请已通过。",
ActionType: "wallet_withdraw_detail",
ActionParam: "withdrawal_id=wd_01",
SentAtMS: 1_800_000_000_000,
}); err != nil || created {
t.Fatalf("duplicate producer event should be idempotent: created=%v err=%v", created, err)
}
if _, _, err := svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: 42,
Producer: "wallet-service",
ProducerEventID: "withdraw-approved-1",
MessageType: messagedomain.SectionSystem,
Title: "不同标题",
Summary: "你的提现申请已通过。",
ActionType: "wallet_withdraw_detail",
ActionParam: "withdrawal_id=wd_01",
SentAtMS: 1_800_000_000_000,
}); !xerr.IsCode(err, xerr.ProducerEventConflict) {
t.Fatalf("conflicting producer event should fail, got %v", err)
}
tabs, err := svc.ListMessageTabs(ctx, 42)
if err != nil {
t.Fatalf("ListMessageTabs failed: %v", err)
}
if len(tabs) != 3 || tabs[0].Section != messagedomain.SectionUser || tabs[0].Source != messagedomain.SourceTencentIMSDK || tabs[1].UnreadCount != 1 || tabs[2].UnreadCount != 1 {
t.Fatalf("unexpected tabs: %+v", tabs)
}
items, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, "")
if err != nil || next != "" || len(items) != 1 || items[0].MessageID != first.MessageID || items[0].Read() {
t.Fatalf("system list mismatch: items=%+v next=%q err=%v", items, next, err)
}
read, err := svc.MarkInboxMessageRead(ctx, 42, first.MessageID)
if err != nil || !read.Read() || read.ReadAtMS != now.UnixMilli() {
t.Fatalf("MarkInboxMessageRead failed: %+v err=%v", read, err)
}
now = now.Add(time.Minute)
readAgain, err := svc.MarkInboxMessageRead(ctx, 42, first.MessageID)
if err != nil || readAgain.ReadAtMS != read.ReadAtMS {
t.Fatalf("repeat read must preserve first read time: first=%+v second=%+v err=%v", read, readAgain, err)
}
readCount, err := svc.MarkInboxSectionRead(ctx, 42, messagedomain.SectionActivity)
if err != nil || readCount != 1 {
t.Fatalf("read-all activity failed: count=%d err=%v", readCount, err)
}
if err := svc.DeleteInboxMessage(ctx, 99, first.MessageID); !xerr.IsCode(err, xerr.MessageNotFound) {
t.Fatalf("other user delete must look not found, got %v", err)
}
if err := svc.DeleteInboxMessage(ctx, 42, first.MessageID); err != nil {
t.Fatalf("DeleteInboxMessage failed: %v", err)
}
items, _, err = svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 20, "")
if err != nil || len(items) != 0 {
t.Fatalf("deleted message should not be listed: items=%+v err=%v", items, err)
}
}
func TestInboxListCursorAndExpiredMessages(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1_800_000_010_000)
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository)
svc.SetClock(func() time.Time { return now })
for _, input := range []messagedomain.CreateInput{
{TargetUserID: 42, Producer: "admin", ProducerEventID: "system-old", MessageType: messagedomain.SectionSystem, Title: "旧消息", Summary: "旧摘要", SentAtMS: 1_800_000_000_000},
{TargetUserID: 42, Producer: "admin", ProducerEventID: "system-new", MessageType: messagedomain.SectionSystem, Title: "新消息", Summary: "新摘要", SentAtMS: 1_800_000_001_000},
{TargetUserID: 42, Producer: "admin", ProducerEventID: "system-expired", MessageType: messagedomain.SectionSystem, Title: "过期消息", Summary: "过期摘要", SentAtMS: 1_800_000_000_000, ExpireAtMS: 1_800_000_005_000},
} {
if _, _, err := svc.CreateInboxMessage(ctx, input); err != nil {
t.Fatalf("CreateInboxMessage failed: %+v err=%v", input, err)
}
}
firstPage, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, "")
if err != nil || len(firstPage) != 1 || firstPage[0].Title != "新消息" || next == "" {
t.Fatalf("first page mismatch: items=%+v next=%q err=%v", firstPage, next, err)
}
secondPage, next, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionSystem, 1, next)
if err != nil || len(secondPage) != 1 || secondPage[0].Title != "旧消息" || next != "" {
t.Fatalf("second page mismatch: items=%+v next=%q err=%v", secondPage, next, err)
}
if _, _, err := svc.ListInboxMessages(ctx, 42, messagedomain.SectionActivity, 1, "bad-token"); !xerr.IsCode(err, xerr.PageTokenInvalid) {
t.Fatalf("invalid cursor should fail with PAGE_TOKEN_INVALID, got %v", err)
}
tabs, err := svc.ListMessageTabs(ctx, 42)
if err != nil || tabs[1].UnreadCount != 2 {
t.Fatalf("expired message must not count unread: tabs=%+v err=%v", tabs, err)
}
}
func TestCreateFanoutJobIsIdempotentAndDetectsCommandConflict(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository)
svc.SetClock(func() time.Time { return time.UnixMilli(1_800_000_020_000) })
input := messagedomain.CreateFanoutInput{
CommandID: "cmd-fanout-1",
MessageType: messagedomain.SectionActivity,
TargetScope: "user_ids",
TargetFilterJSON: `{"user_ids":[42,43]}`,
TemplateSnapshotJSON: `{"title":"活动上线","summary":"新活动已上线"}`,
BatchSize: 500,
CreatedBy: "admin:1",
}
job, created, err := svc.CreateFanoutJob(ctx, input)
if err != nil || !created || job.JobID == "" || job.Status != messagedomain.FanoutStatusPending {
t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err)
}
retry, created, err := svc.CreateFanoutJob(ctx, input)
if err != nil || created || retry.JobID != job.JobID {
t.Fatalf("retry should return existing job: created=%v retry=%+v err=%v", created, retry, err)
}
input.TargetFilterJSON = `{"user_ids":[99]}`
if _, _, err := svc.CreateFanoutJob(ctx, input); !xerr.IsCode(err, xerr.RequestConflict) {
t.Fatalf("conflicting fanout command should fail, got %v", err)
}
}
func TestFanoutWorkerMaterializesUserIDTargetsByCursor(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "lalu")
repository := mysqltest.NewRepository(t)
now := time.UnixMilli(1_800_000_030_000)
svc := messageservice.New(messageservice.Config{NodeID: "activity-a"}, repository)
svc.SetClock(func() time.Time { return now })
job, created, err := svc.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{
CommandID: "cmd-fanout-worker-1",
MessageType: messagedomain.SectionActivity,
TargetScope: messagedomain.TargetScopeUserIDs,
TargetFilterJSON: `{"user_ids":[44,42,43,42]}`,
TemplateSnapshotJSON: `{"title":"活动上线","summary":"新活动已上线","action_type":"activity_detail","action_param":"activity_id=act_01","metadata_json":{"campaign":"spring"}}`,
BatchSize: 2,
CreatedBy: "admin:1",
})
if err != nil || !created {
t.Fatalf("CreateFanoutJob failed: created=%v job=%+v err=%v", created, job, err)
}
processed, err := svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 2, MaxRetry: 3, LockTTL: time.Minute})
if err != nil || !processed {
t.Fatalf("first fanout batch failed: processed=%v err=%v", processed, err)
}
stored, exists, err := repository.GetFanoutJobByCommand(ctx, "cmd-fanout-worker-1")
if err != nil || !exists || stored.Status != messagedomain.FanoutStatusPending || stored.CursorUserID != 43 || stored.SuccessCount != 2 || stored.TotalCount != 2 || stored.AttemptCount != 1 {
t.Fatalf("first batch progress mismatch: exists=%v job=%+v err=%v", exists, stored, err)
}
for _, userID := range []int64{42, 43} {
items, _, err := svc.ListInboxMessages(ctx, userID, messagedomain.SectionActivity, 20, "")
if err != nil || len(items) != 1 || items[0].Title != "活动上线" || items[0].ActionType != "activity_detail" {
t.Fatalf("user %d inbox mismatch after first batch: items=%+v err=%v", userID, items, err)
}
}
now = now.Add(time.Second)
processed, err = svc.ProcessNextFanoutJob(ctx, messageservice.FanoutWorkerOptions{WorkerID: "worker-a", BatchSize: 2, MaxRetry: 3, LockTTL: time.Minute})
if err != nil || !processed {
t.Fatalf("second fanout batch failed: processed=%v err=%v", processed, err)
}
stored, exists, err = repository.GetFanoutJobByCommand(ctx, "cmd-fanout-worker-1")
if err != nil || !exists || stored.Status != messagedomain.FanoutStatusComplete || stored.CursorUserID != 44 || stored.SuccessCount != 3 || stored.TotalCount != 3 || stored.AttemptCount != 2 {
t.Fatalf("second batch progress mismatch: exists=%v job=%+v err=%v", exists, stored, err)
}
items, _, err := svc.ListInboxMessages(ctx, 44, messagedomain.SectionActivity, 20, "")
if err != nil || len(items) != 1 || items[0].Summary != "新活动已上线" {
t.Fatalf("last user inbox mismatch: items=%+v err=%v", items, err)
}
}

View File

@ -0,0 +1,564 @@
package mysql
import (
"context"
"database/sql"
"errors"
mysqlerr "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
// SaveInboxMessage inserts one materialized user inbox row and returns an existing row on producer idempotency hits.
func (r *Repository) SaveInboxMessage(ctx context.Context, message messagedomain.InboxMessage) (bool, messagedomain.InboxMessage, error) {
if r == nil || r.db == nil {
return false, messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
app := appcode.FromContext(ctx)
message.AppCode = app
_, err := r.db.ExecContext(ctx, `
INSERT INTO user_inbox_messages (
app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
title, summary, body, icon_url, image_url, action_type, action_param, priority,
status, read_at_ms, deleted_at_ms, sent_at_ms, expire_at_ms, metadata_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`,
app,
message.MessageID,
message.UserID,
message.MessageType,
message.Producer,
message.ProducerEventID,
message.ProducerEventType,
message.AggregateType,
message.AggregateID,
message.TemplateID,
message.TemplateVersion,
message.Title,
message.Summary,
message.Body,
message.IconURL,
message.ImageURL,
message.ActionType,
message.ActionParam,
message.Priority,
message.Status,
message.SentAtMS,
nullInt64(message.ExpireAtMS),
nullString(message.MetadataJSON),
message.CreatedAtMS,
message.UpdatedAtMS,
)
if err == nil {
return true, message, nil
}
if !isMySQLDuplicate(err) {
return false, messagedomain.InboxMessage{}, err
}
existing, exists, findErr := r.GetInboxMessageByProducerEvent(ctx, message.UserID, message.Producer, message.ProducerEventID)
if findErr != nil {
return false, messagedomain.InboxMessage{}, findErr
}
if exists {
return false, existing, nil
}
return false, messagedomain.InboxMessage{}, xerr.New(xerr.Conflict, "inbox message id already exists")
}
// GetInboxMessageByProducerEvent reads the idempotency target for a producer event.
func (r *Repository) GetInboxMessageByProducerEvent(ctx context.Context, userID int64, producer string, producerEventID string) (messagedomain.InboxMessage, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
title, summary, body, icon_url, image_url, action_type, action_param, priority,
status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
FROM user_inbox_messages
WHERE app_code = ? AND user_id = ? AND producer = ? AND producer_event_id = ?`,
appcode.FromContext(ctx), userID, producer, producerEventID,
)
message, err := scanInboxMessage(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return messagedomain.InboxMessage{}, false, nil
}
return messagedomain.InboxMessage{}, false, err
}
return message, true, nil
}
// GetActiveMessageTemplate resolves a server-owned snapshot template.
func (r *Repository) GetActiveMessageTemplate(ctx context.Context, templateID string, templateVersion string, messageType string) (messagedomain.MessageTemplate, bool, error) {
if r == nil || r.db == nil {
return messagedomain.MessageTemplate{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
row := r.db.QueryRowContext(ctx, `
SELECT app_code, template_id, template_version, message_type, locale, title, summary, body,
icon_url, image_url, action_type, action_param, status, created_by, created_at_ms, updated_at_ms
FROM message_templates
WHERE app_code = ? AND template_id = ? AND template_version = ? AND message_type = ? AND status = 'active'`,
appcode.FromContext(ctx), templateID, templateVersion, messageType,
)
var template messagedomain.MessageTemplate
if err := row.Scan(&template.AppCode, &template.TemplateID, &template.TemplateVersion, &template.MessageType, &template.Locale, &template.Title, &template.Summary, &template.Body, &template.IconURL, &template.ImageURL, &template.ActionType, &template.ActionParam, &template.Status, &template.CreatedBy, &template.CreatedAtMS, &template.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return messagedomain.MessageTemplate{}, false, nil
}
return messagedomain.MessageTemplate{}, false, err
}
return template, true, nil
}
// CountUnreadInboxMessages counts visible, unexpired and undeleted unread messages by section.
func (r *Repository) CountUnreadInboxMessages(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) {
if r == nil || r.db == nil {
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM user_inbox_messages
WHERE app_code = ? AND user_id = ? AND message_type = ?
AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL
AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
appcode.FromContext(ctx), userID, section, nowMS,
)
var count int64
if err := row.Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// ListInboxMessages reads one cursor page of visible messages for the current app and user.
func (r *Repository) ListInboxMessages(ctx context.Context, query messagedomain.ListQuery) ([]messagedomain.InboxMessage, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
args := []any{appcode.FromContext(ctx), query.UserID, query.Section, query.NowMS}
cursorSQL := ""
if query.CursorSentAtMS > 0 || query.CursorMessageID != "" {
cursorSQL = " AND (sent_at_ms < ? OR (sent_at_ms = ? AND inbox_message_id < ?))"
args = append(args, query.CursorSentAtMS, query.CursorSentAtMS, query.CursorMessageID)
}
args = append(args, query.Limit)
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
title, summary, body, icon_url, image_url, action_type, action_param, priority,
status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
FROM user_inbox_messages
WHERE app_code = ? AND user_id = ? AND message_type = ?
AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`+cursorSQL+`
ORDER BY sent_at_ms DESC, inbox_message_id DESC
LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
messages := make([]messagedomain.InboxMessage, 0, query.Limit)
for rows.Next() {
message, scanErr := scanInboxMessage(rows)
if scanErr != nil {
return nil, scanErr
}
messages = append(messages, message)
}
if err := rows.Err(); err != nil {
return nil, err
}
return messages, nil
}
// MarkInboxMessageRead preserves the first read timestamp and treats repeated reads as success.
func (r *Repository) MarkInboxMessageRead(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, error) {
if r == nil || r.db == nil {
return messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
message, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS)
if err != nil {
return messagedomain.InboxMessage{}, err
}
if !exists {
return messagedomain.InboxMessage{}, xerr.New(xerr.MessageNotFound, "message not found")
}
if message.ReadAtMS == 0 {
_, err = r.db.ExecContext(ctx, `
UPDATE user_inbox_messages
SET read_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND read_at_ms IS NULL`,
nowMS, nowMS, appcode.FromContext(ctx), userID, messageID,
)
if err != nil {
return messagedomain.InboxMessage{}, err
}
message, _, err = r.getVisibleInboxMessage(ctx, userID, messageID, nowMS)
if err != nil {
return messagedomain.InboxMessage{}, err
}
}
return message, nil
}
// MarkInboxSectionRead marks all current visible unread messages in a section.
func (r *Repository) MarkInboxSectionRead(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) {
if r == nil || r.db == nil {
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
result, err := r.db.ExecContext(ctx, `
UPDATE user_inbox_messages
SET read_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND message_type = ?
AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL
AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
nowMS, nowMS, appcode.FromContext(ctx), userID, section, nowMS,
)
if err != nil {
return 0, err
}
count, err := result.RowsAffected()
if err != nil {
return 0, err
}
return count, nil
}
// DeleteInboxMessage hides one visible message for the current user without deleting audit facts.
func (r *Repository) DeleteInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if _, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS); err != nil {
return err
} else if !exists {
return xerr.New(xerr.MessageNotFound, "message not found")
}
_, err := r.db.ExecContext(ctx, `
UPDATE user_inbox_messages
SET deleted_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND deleted_at_ms IS NULL`,
nowMS, nowMS, appcode.FromContext(ctx), userID, messageID,
)
return err
}
// SaveFanoutJob records an async fanout command; worker execution is intentionally outside HTTP request path.
func (r *Repository) SaveFanoutJob(ctx context.Context, job messagedomain.FanoutJob) (bool, messagedomain.FanoutJob, error) {
if r == nil || r.db == nil {
return false, messagedomain.FanoutJob{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
app := appcode.FromContext(ctx)
job.AppCode = app
_, err := r.db.ExecContext(ctx, `
INSERT INTO message_fanout_jobs (
app_code, job_id, command_id, message_type, target_scope, target_filter_json, template_snapshot_json,
status, attempt_count, cursor_user_id, total_count, success_count, failure_count, batch_size, next_run_at_ms,
locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
app,
job.JobID,
job.CommandID,
job.MessageType,
job.TargetScope,
job.TargetFilterJSON,
job.TemplateSnapshotJSON,
job.Status,
job.AttemptCount,
job.CursorUserID,
job.TotalCount,
job.SuccessCount,
job.FailureCount,
job.BatchSize,
job.NextRunAtMS,
job.LockedBy,
job.LockedUntilMS,
job.ErrorMessage,
job.CreatedBy,
job.CreatedAtMS,
job.UpdatedAtMS,
)
if err == nil {
return true, job, nil
}
if !isMySQLDuplicate(err) {
return false, messagedomain.FanoutJob{}, err
}
existing, exists, findErr := r.GetFanoutJobByCommand(ctx, job.CommandID)
if findErr != nil {
return false, messagedomain.FanoutJob{}, findErr
}
if exists {
return false, existing, nil
}
return false, messagedomain.FanoutJob{}, xerr.New(xerr.Conflict, "fanout job id already exists")
}
// GetFanoutJobByCommand reads the idempotency target for admin command retries.
func (r *Repository) GetFanoutJobByCommand(ctx context.Context, commandID string) (messagedomain.FanoutJob, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR),
CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count,
batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
FROM message_fanout_jobs
WHERE app_code = ? AND command_id = ?`,
appcode.FromContext(ctx), commandID,
)
var job messagedomain.FanoutJob
if err := scanFanoutJob(row, &job); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return messagedomain.FanoutJob{}, false, nil
}
return messagedomain.FanoutJob{}, false, err
}
return job, true, nil
}
// ClaimFanoutJob commits ownership before processing so another worker can take over after lock expiry.
func (r *Repository) ClaimFanoutJob(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, maxRetry int) (messagedomain.FanoutJob, bool, error) {
if r == nil || r.db == nil {
return messagedomain.FanoutJob{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
defer tx.Rollback()
row := tx.QueryRowContext(ctx, `
SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR),
CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count,
batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms
FROM message_fanout_jobs
WHERE next_run_at_ms <= ? AND attempt_count < ?
AND (status IN (?, ?) OR (status = ? AND locked_until_ms < ?))
ORDER BY next_run_at_ms ASC, updated_at_ms ASC, job_id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`,
nowMS,
maxRetry,
messagedomain.FanoutStatusPending,
messagedomain.FanoutStatusRetrying,
messagedomain.FanoutStatusRunning,
nowMS,
)
var job messagedomain.FanoutJob
if err := scanFanoutJob(row, &job); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if commitErr := tx.Commit(); commitErr != nil {
return messagedomain.FanoutJob{}, false, commitErr
}
return messagedomain.FanoutJob{}, false, nil
}
return messagedomain.FanoutJob{}, false, err
}
_, err = tx.ExecContext(ctx, `
UPDATE message_fanout_jobs
SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ?
WHERE app_code = ? AND job_id = ?`,
messagedomain.FanoutStatusRunning, workerID, lockUntilMS, nowMS, job.AppCode, job.JobID,
)
if err != nil {
return messagedomain.FanoutJob{}, false, err
}
if err := tx.Commit(); err != nil {
return messagedomain.FanoutJob{}, false, err
}
job.Status = messagedomain.FanoutStatusRunning
job.LockedBy = workerID
job.LockedUntilMS = lockUntilMS
job.AttemptCount++
job.UpdatedAtMS = nowMS
return job, true, nil
}
// FinishFanoutJobBatch commits one processed cursor page and clears the durable worker lock.
func (r *Repository) FinishFanoutJobBatch(ctx context.Context, jobID string, workerID string, cursorUserID int64, totalDelta int64, successDelta int64, failureDelta int64, done bool, nowMS int64, errorMessage string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
status := messagedomain.FanoutStatusPending
if done {
status = messagedomain.FanoutStatusComplete
}
result, err := r.db.ExecContext(ctx, `
UPDATE message_fanout_jobs
SET status = ?, cursor_user_id = ?, total_count = total_count + ?, success_count = success_count + ?,
failure_count = failure_count + ?, next_run_at_ms = ?, locked_by = '', locked_until_ms = 0,
error_message = ?, updated_at_ms = ?
WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`,
status,
cursorUserID,
totalDelta,
successDelta,
failureDelta,
nowMS,
truncateFanoutError(errorMessage),
nowMS,
appcode.FromContext(ctx),
jobID,
messagedomain.FanoutStatusRunning,
workerID,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return xerr.New(xerr.Conflict, "fanout job lock is lost")
}
return nil
}
// FailFanoutJob releases a failed claim; exhausted jobs become failed instead of retrying forever.
func (r *Repository) FailFanoutJob(ctx context.Context, jobID string, workerID string, nowMS int64, nextRunAtMS int64, maxRetry int, errorMessage string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
result, err := r.db.ExecContext(ctx, `
UPDATE message_fanout_jobs
SET status = CASE WHEN attempt_count >= ? THEN ? ELSE ? END,
next_run_at_ms = CASE WHEN attempt_count >= ? THEN 0 ELSE ? END,
locked_by = '', locked_until_ms = 0, error_message = ?, updated_at_ms = ?
WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`,
maxRetry,
messagedomain.FanoutStatusFailed,
messagedomain.FanoutStatusRetrying,
maxRetry,
nextRunAtMS,
truncateFanoutError(errorMessage),
nowMS,
appcode.FromContext(ctx),
jobID,
messagedomain.FanoutStatusRunning,
workerID,
)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return xerr.New(xerr.Conflict, "fanout job lock is lost")
}
return nil
}
func (r *Repository) getVisibleInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id,
producer_event_type, aggregate_type, aggregate_id, template_id, template_version,
title, summary, body, icon_url, image_url, action_type, action_param, priority,
status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0),
COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms
FROM user_inbox_messages
WHERE app_code = ? AND user_id = ? AND inbox_message_id = ?
AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`,
appcode.FromContext(ctx), userID, messageID, nowMS,
)
message, err := scanInboxMessage(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return messagedomain.InboxMessage{}, false, nil
}
return messagedomain.InboxMessage{}, false, err
}
return message, true, nil
}
type inboxScanner interface {
Scan(dest ...any) error
}
func scanFanoutJob(scanner inboxScanner, job *messagedomain.FanoutJob) error {
return scanner.Scan(
&job.AppCode,
&job.JobID,
&job.CommandID,
&job.MessageType,
&job.TargetScope,
&job.TargetFilterJSON,
&job.TemplateSnapshotJSON,
&job.Status,
&job.AttemptCount,
&job.CursorUserID,
&job.TotalCount,
&job.SuccessCount,
&job.FailureCount,
&job.BatchSize,
&job.NextRunAtMS,
&job.LockedBy,
&job.LockedUntilMS,
&job.ErrorMessage,
&job.CreatedBy,
&job.CreatedAtMS,
&job.UpdatedAtMS,
)
}
func scanInboxMessage(scanner inboxScanner) (messagedomain.InboxMessage, error) {
var message messagedomain.InboxMessage
err := scanner.Scan(
&message.AppCode,
&message.MessageID,
&message.UserID,
&message.MessageType,
&message.Producer,
&message.ProducerEventID,
&message.ProducerEventType,
&message.AggregateType,
&message.AggregateID,
&message.TemplateID,
&message.TemplateVersion,
&message.Title,
&message.Summary,
&message.Body,
&message.IconURL,
&message.ImageURL,
&message.ActionType,
&message.ActionParam,
&message.Priority,
&message.Status,
&message.ReadAtMS,
&message.DeletedAtMS,
&message.SentAtMS,
&message.ExpireAtMS,
&message.MetadataJSON,
&message.CreatedAtMS,
&message.UpdatedAtMS,
)
return message, err
}
func nullInt64(value int64) sql.NullInt64 {
return sql.NullInt64{Int64: value, Valid: value > 0}
}
func nullString(value string) sql.NullString {
return sql.NullString{String: value, Valid: value != ""}
}
func truncateFanoutError(message string) string {
if len(message) <= 512 {
return message
}
return message[:512]
}
func isMySQLDuplicate(err error) bool {
var mysqlErr *mysqlerr.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

View File

@ -0,0 +1,143 @@
package grpc
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
messageservice "hyapp/services/activity-service/internal/service/message"
)
// MessageServer adapts the message inbox owner to gRPC without mixing it into activity status APIs.
type MessageServer struct {
activityv1.UnimplementedMessageInboxServiceServer
svc *messageservice.Service
}
// NewMessageServer creates the gRPC adapter for system/activity inbox RPCs.
func NewMessageServer(svc *messageservice.Service) *MessageServer {
return &MessageServer{svc: svc}
}
func (s *MessageServer) ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
sections, err := s.svc.ListMessageTabs(ctx, req.GetUserId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
result := make([]*activityv1.MessageTabSection, 0, len(sections))
for _, section := range sections {
result = append(result, &activityv1.MessageTabSection{
Section: section.Section,
Title: section.Title,
UnreadCount: section.UnreadCount,
Source: section.Source,
})
}
return &activityv1.ListMessageTabsResponse{Sections: result}, nil
}
func (s *MessageServer) ListInboxMessages(ctx context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
messages, nextPageToken, err := s.svc.ListInboxMessages(ctx, req.GetUserId(), req.GetSection(), req.GetPageSize(), req.GetPageToken())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
items := make([]*activityv1.InboxMessage, 0, len(messages))
for _, message := range messages {
items = append(items, inboxMessageProto(message))
}
return &activityv1.ListInboxMessagesResponse{Section: req.GetSection(), Items: items, NextPageToken: nextPageToken}, nil
}
func (s *MessageServer) MarkInboxMessageRead(ctx context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
message, err := s.svc.MarkInboxMessageRead(ctx, req.GetUserId(), req.GetMessageId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.MarkInboxMessageReadResponse{MessageId: message.MessageID, Read: true, ReadAtMs: message.ReadAtMS}, nil
}
func (s *MessageServer) MarkInboxSectionRead(ctx context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
count, err := s.svc.MarkInboxSectionRead(ctx, req.GetUserId(), req.GetSection())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.MarkInboxSectionReadResponse{Section: req.GetSection(), ReadCount: count}, nil
}
func (s *MessageServer) DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
if err := s.svc.DeleteInboxMessage(ctx, req.GetUserId(), req.GetMessageId()); err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.DeleteInboxMessageResponse{MessageId: req.GetMessageId(), Deleted: true}, nil
}
func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
message, created, err := s.svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
TargetUserID: req.GetTargetUserId(),
Producer: req.GetProducer(),
ProducerEventID: req.GetProducerEventId(),
ProducerEventType: req.GetProducerEventType(),
MessageType: req.GetMessageType(),
AggregateType: req.GetAggregateType(),
AggregateID: req.GetAggregateId(),
TemplateID: req.GetTemplateId(),
TemplateVersion: req.GetTemplateVersion(),
Title: req.GetTitle(),
Summary: req.GetSummary(),
Body: req.GetBody(),
IconURL: req.GetIconUrl(),
ImageURL: req.GetImageUrl(),
ActionType: req.GetActionType(),
ActionParam: req.GetActionParam(),
Priority: req.GetPriority(),
SentAtMS: req.GetSentAtMs(),
ExpireAtMS: req.GetExpireAtMs(),
MetadataJSON: req.GetMetadataJson(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.CreateInboxMessageResponse{MessageId: message.MessageID, Created: created}, nil
}
func (s *MessageServer) CreateFanoutJob(ctx context.Context, req *activityv1.CreateFanoutJobRequest) (*activityv1.CreateFanoutJobResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
job, created, err := s.svc.CreateFanoutJob(ctx, messagedomain.CreateFanoutInput{
CommandID: req.GetCommandId(),
MessageType: req.GetMessageType(),
TargetScope: req.GetTargetScope(),
TargetFilterJSON: req.GetTargetFilterJson(),
TemplateSnapshotJSON: req.GetTemplateSnapshotJson(),
BatchSize: req.GetBatchSize(),
CreatedBy: req.GetCreatedBy(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.CreateFanoutJobResponse{JobId: job.JobID, Status: job.Status, Created: created}, nil
}
func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMessage {
return &activityv1.InboxMessage{
MessageId: message.MessageID,
Section: message.MessageType,
Title: message.Title,
Summary: message.Summary,
Body: message.Body,
IconUrl: message.IconURL,
ImageUrl: message.ImageURL,
ActionType: message.ActionType,
ActionParam: message.ActionParam,
Read: message.Read(),
SentAtMs: message.SentAtMS,
}
}

View File

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

View File

@ -3,6 +3,7 @@ jwt_secret: "REPLACE_WITH_GATEWAY_JWT_SECRET"
room_service_addr: "room-service.internal:13001"
user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004"
activity_service_addr: "activity-service.internal:13006"
app_config:
# H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configsgateway 只读下发给 App。
mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local"

View File

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

View File

@ -24,15 +24,16 @@ import (
// App 装配 gateway 的 HTTP 入口。
type App struct {
server *http.Server
listener net.Listener
roomConn *grpc.ClientConn
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
appConfig *appconfig.MySQLReader
redisClose func() error
health *healthcheck.State
closeOnce sync.Once
server *http.Server
listener net.Listener
roomConn *grpc.ClientConn
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
activityConn *grpc.ClientConn
appConfig *appconfig.MySQLReader
redisClose func() error
health *healthcheck.State
closeOnce sync.Once
}
// New 初始化 gateway 应用。
@ -58,6 +59,13 @@ func New(cfg config.Config) (*App, error) {
_ = userConn.Close()
return nil, err
}
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
return nil, err
}
var roomClient client.RoomClient = client.NewGRPCRoomClient(roomConn)
var roomGuardClient client.RoomGuardClient = client.NewGRPCRoomGuardClient(roomConn)
@ -65,15 +73,18 @@ func New(cfg config.Config) (*App, error) {
var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn)
var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
var userHostClient client.UserHostClient = client.NewGRPCUserHostClient(userConn)
var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
if err != nil {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{
@ -83,10 +94,12 @@ func New(cfg config.Config) (*App, error) {
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
}, userProfileClient)
handler.SetRoomQueryClient(roomQueryClient)
handler.SetUserDeviceClient(userDeviceClient)
handler.SetUserCountryQueryClient(userCountryQueryClient)
handler.SetUserHostClient(userHostClient)
handler.SetAppRegistryClient(appRegistryClient)
handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient)
if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader)
}
@ -114,6 +127,7 @@ func New(cfg config.Config) (*App, error) {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
handler.SetObjectUploader(uploader)
@ -137,6 +151,7 @@ func New(cfg config.Config) (*App, error) {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
verifier := auth.NewVerifier(cfg.JWTSecret)
@ -159,6 +174,7 @@ func New(cfg config.Config) (*App, error) {
_ = roomConn.Close()
_ = userConn.Close()
_ = walletConn.Close()
_ = activityConn.Close()
return nil, err
}
@ -167,14 +183,15 @@ func New(cfg config.Config) (*App, error) {
}
return &App{
server: server,
listener: listener,
roomConn: roomConn,
userConn: userConn,
walletConn: walletConn,
appConfig: appConfigReader,
redisClose: redisClose,
health: healthState,
server: server,
listener: listener,
roomConn: roomConn,
userConn: userConn,
walletConn: walletConn,
activityConn: activityConn,
appConfig: appConfigReader,
redisClose: redisClose,
health: healthState,
}, nil
}
@ -236,6 +253,9 @@ func (a *App) Close() error {
if a.walletConn != nil {
err = errors.Join(err, a.walletConn.Close())
}
if a.activityConn != nil {
err = errors.Join(err, a.activityConn.Close())
}
if a.appConfig != nil {
err = errors.Join(err, a.appConfig.Close())
}

View File

@ -0,0 +1,46 @@
package client
import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
)
// MessageInboxClient abstracts gateway access to the backend-owned system/activity inbox.
type MessageInboxClient interface {
ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error)
ListInboxMessages(ctx context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error)
MarkInboxMessageRead(ctx context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error)
MarkInboxSectionRead(ctx context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error)
DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error)
}
type grpcMessageInboxClient struct {
client activityv1.MessageInboxServiceClient
}
// NewGRPCMessageInboxClient builds an inbox client backed by activity-service MessageInboxService.
func NewGRPCMessageInboxClient(conn *grpc.ClientConn) MessageInboxClient {
return &grpcMessageInboxClient{client: activityv1.NewMessageInboxServiceClient(conn)}
}
func (c *grpcMessageInboxClient) ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
return c.client.ListMessageTabs(ctx, req)
}
func (c *grpcMessageInboxClient) ListInboxMessages(ctx context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error) {
return c.client.ListInboxMessages(ctx, req)
}
func (c *grpcMessageInboxClient) MarkInboxMessageRead(ctx context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error) {
return c.client.MarkInboxMessageRead(ctx, req)
}
func (c *grpcMessageInboxClient) MarkInboxSectionRead(ctx context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error) {
return c.client.MarkInboxSectionRead(ctx, req)
}
func (c *grpcMessageInboxClient) DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
return c.client.DeleteInboxMessage(ctx, req)
}

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)
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
}
type grpcRoomClient struct {
@ -152,3 +154,11 @@ func (c *grpcRoomGuardClient) VerifyRoomPresence(ctx context.Context, req *roomv
func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
return c.client.ListRooms(ctx, req)
}
func (c *grpcRoomQueryClient) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
return c.client.GetCurrentRoom(ctx, req)
}
func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
return c.client.GetRoomSnapshot(ctx, req)
}

View File

@ -28,11 +28,18 @@ type UserIdentityClient interface {
// UserProfileClient 抽象 gateway 对 user-service 用户资料能力的依赖。
type UserProfileClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, 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)
}
// UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。
type UserDeviceClient interface {
BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error)
DeletePushToken(ctx context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error)
}
// UserCountryQueryClient 抽象 gateway 对 App 注册页国家列表的公开读依赖。
type UserCountryQueryClient interface {
ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error)
@ -62,6 +69,10 @@ type grpcUserProfileClient struct {
client userv1.UserServiceClient
}
type grpcUserDeviceClient struct {
client userv1.UserDeviceServiceClient
}
type grpcUserCountryQueryClient struct {
client userv1.CountryQueryServiceClient
}
@ -95,6 +106,13 @@ func NewGRPCUserProfileClient(conn *grpc.ClientConn) UserProfileClient {
}
}
// NewGRPCUserDeviceClient 用 gRPC 连接构造 user-service 设备推送 client。
func NewGRPCUserDeviceClient(conn *grpc.ClientConn) UserDeviceClient {
return &grpcUserDeviceClient{
client: userv1.NewUserDeviceServiceClient(conn),
}
}
// NewGRPCUserCountryQueryClient 用 gRPC 连接构造国家公开查询 client。
func NewGRPCUserCountryQueryClient(conn *grpc.ClientConn) UserCountryQueryClient {
return &grpcUserCountryQueryClient{
@ -156,6 +174,10 @@ func (c *grpcUserProfileClient) GetUser(ctx context.Context, req *userv1.GetUser
return c.client.GetUser(ctx, req)
}
func (c *grpcUserProfileClient) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
return c.client.BatchGetUsers(ctx, req)
}
func (c *grpcUserProfileClient) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
return c.client.CompleteOnboarding(ctx, req)
}
@ -168,6 +190,14 @@ func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *user
return c.client.ChangeUserCountry(ctx, req)
}
func (c *grpcUserDeviceClient) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
return c.client.BindPushToken(ctx, req)
}
func (c *grpcUserDeviceClient) DeletePushToken(ctx context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) {
return c.client.DeletePushToken(ctx, req)
}
func (c *grpcUserCountryQueryClient) ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
return c.client.ListRegistrationCountries(ctx, req)
}

View File

@ -11,16 +11,17 @@ import (
// Config 描述 gateway-service 启动所需的最小配置。
type Config struct {
HTTPAddr string `yaml:"http_addr"`
JWTSecret string `yaml:"jwt_secret"`
RoomServiceAddr string `yaml:"room_service_addr"`
UserServiceAddr string `yaml:"user_service_addr"`
WalletServiceAddr string `yaml:"wallet_service_addr"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
AppConfig AppConfigConfig `yaml:"app_config"`
TencentIM TencentIMConfig `yaml:"tencent_im"`
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
HTTPAddr string `yaml:"http_addr"`
JWTSecret string `yaml:"jwt_secret"`
RoomServiceAddr string `yaml:"room_service_addr"`
UserServiceAddr string `yaml:"user_service_addr"`
WalletServiceAddr string `yaml:"wallet_service_addr"`
ActivityServiceAddr string `yaml:"activity_service_addr"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
AppConfig AppConfigConfig `yaml:"app_config"`
TencentIM TencentIMConfig `yaml:"tencent_im"`
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
}
// AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。
@ -109,11 +110,12 @@ type TencentCOSConfig struct {
// Default 返回本地开发默认配置。
func Default() Config {
return Config{
HTTPAddr: ":13000",
JWTSecret: "gateway-dev-secret",
RoomServiceAddr: "127.0.0.1:13001",
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
HTTPAddr: ":13000",
JWTSecret: "gateway-dev-secret",
RoomServiceAddr: "127.0.0.1:13001",
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006",
AppConfig: AppConfigConfig{
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local",
},
@ -167,9 +169,13 @@ func (cfg *Config) Normalize() error {
cfg.RoomServiceAddr = strings.TrimSpace(cfg.RoomServiceAddr)
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr)
if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004"
}
if cfg.ActivityServiceAddr == "" {
cfg.ActivityServiceAddr = "127.0.0.1:13006"
}
cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN)
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)

View File

@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/appconfig"
@ -16,6 +17,57 @@ type appConfigReader interface {
ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error)
}
type appBootstrapBottomTab struct {
Key string `json:"key"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
}
type appBootstrapResponse struct {
AppCode string `json:"app_code"`
ServerTimeMS int64 `json:"server_time_ms"`
ForceUpgrade bool `json:"force_upgrade"`
Maintenance bool `json:"maintenance"`
MinimumVersion string `json:"minimum_version"`
LatestVersion string `json:"latest_version"`
FeatureFlags map[string]bool `json:"feature_flags"`
BottomTabs []appBootstrapBottomTab `json:"bottom_tabs"`
ConfigVersions map[string]string `json:"config_versions"`
}
// getAppBootstrap 返回 App 启动状态机需要的轻量配置摘要。
// bootstrap 只下发版本、开关和配置版本号,不返回 banner、礼物或资源大列表。
func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Request) {
writeOK(writer, request, appBootstrapResponse{
AppCode: appcode.FromContext(request.Context()),
ServerTimeMS: time.Now().UnixMilli(),
ForceUpgrade: false,
Maintenance: false,
MinimumVersion: "1.0.0",
LatestVersion: "1.0.0",
FeatureFlags: map[string]bool{
"wallet_recharge": true,
"wallet_withdraw": true,
"diamond_exchange": true,
"message_tab": true,
"host_center": true,
"room_create": true,
},
BottomTabs: []appBootstrapBottomTab{
{Key: "rooms", Title: "房间", Enabled: true},
{Key: "messages", Title: "消息", Enabled: true},
{Key: "me", Title: "我的", Enabled: true},
},
ConfigVersions: map[string]string{
"countries": "v1",
"banners": "v1",
"h5_links": "v1",
"resources": "v1",
"gifts": "v1",
},
})
}
// listH5Links 返回后台 APP配置/H5配置 中维护的 H5 入口地址。
func (h *Handler) listH5Links(writer http.ResponseWriter, request *http.Request) {
if h.appConfigReader == nil {

View File

@ -0,0 +1,130 @@
package http
import (
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
)
// handlePushToken 分发 push token 绑定和失效。
// 设备 token 属于 user-service 主数据扩展gateway 只做鉴权、字段透传和 HTTP method 约束。
func (h *Handler) handlePushToken(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodPost:
h.bindPushToken(writer, request)
case http.MethodDelete:
h.deletePushToken(writer, request)
default:
writeError(writer, request, http.StatusMethodNotAllowed, codeInvalidArgument, "invalid argument")
}
}
func (h *Handler) bindPushToken(writer http.ResponseWriter, request *http.Request) {
if h.userDeviceClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body struct {
DeviceID string `json:"device_id"`
PushToken string `json:"push_token"`
Provider string `json:"provider"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
}
body.DeviceID = pushDeviceID(request, body.DeviceID)
body.Platform = pushPlatform(request, body.Platform)
resp, err := h.userDeviceClient.BindPushToken(request.Context(), &userv1.BindPushTokenRequest{
Meta: authRequestMeta(request, body.DeviceID),
UserId: auth.UserIDFromContext(request.Context()),
DeviceId: body.DeviceID,
PushToken: strings.TrimSpace(body.PushToken),
Provider: body.Provider,
Platform: body.Platform,
AppVersion: body.AppVersion,
Language: body.Language,
Timezone: body.Timezone,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, bindPushTokenDataFromProto(resp))
}
func (h *Handler) deletePushToken(writer http.ResponseWriter, request *http.Request) {
if h.userDeviceClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body struct {
DeviceID string `json:"device_id"`
PushToken string `json:"push_token"`
}
if !decode(writer, request, &body) {
return
}
body.DeviceID = pushDeviceID(request, body.DeviceID)
resp, err := h.userDeviceClient.DeletePushToken(request.Context(), &userv1.DeletePushTokenRequest{
Meta: authRequestMeta(request, body.DeviceID),
UserId: auth.UserIDFromContext(request.Context()),
DeviceId: body.DeviceID,
PushToken: strings.TrimSpace(body.PushToken),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, deletePushTokenDataFromProto(resp))
}
// bindPushTokenData/deletePushTokenData 固定外部 JSON 字段,避免 proto3 false 被省略。
type bindPushTokenData struct {
Bound bool `json:"bound"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type deletePushTokenData struct {
Deleted bool `json:"deleted"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func bindPushTokenDataFromProto(resp *userv1.BindPushTokenResponse) bindPushTokenData {
if resp == nil {
return bindPushTokenData{}
}
return bindPushTokenData{Bound: resp.GetBound(), UpdatedAtMS: resp.GetUpdatedAtMs()}
}
func deletePushTokenDataFromProto(resp *userv1.DeletePushTokenResponse) deletePushTokenData {
if resp == nil {
return deletePushTokenData{}
}
return deletePushTokenData{Deleted: resp.GetDeleted(), UpdatedAtMS: resp.GetUpdatedAtMs()}
}
func pushDeviceID(request *http.Request, bodyValue string) string {
if value := strings.TrimSpace(bodyValue); value != "" {
return value
}
return firstHeader(request, "X-Device-ID", "X-HY-Device-ID")
}
func pushPlatform(request *http.Request, bodyValue string) string {
if value := strings.TrimSpace(bodyValue); value != "" {
return value
}
return firstHeader(request, "X-App-Platform", "X-Platform")
}

View File

@ -24,10 +24,12 @@ type Handler struct {
userClient client.UserAuthClient
userIdentityClient client.UserIdentityClient
userProfileClient client.UserProfileClient
userDeviceClient client.UserDeviceClient
userCountryClient client.UserCountryQueryClient
userHostClient client.UserHostClient
appRegistryClient client.AppRegistryClient
walletClient client.WalletClient
messageClient client.MessageInboxClient
appConfigReader appConfigReader
tencentIM TencentIMConfig
tencentRTC TencentRTCConfig
@ -123,6 +125,11 @@ func (h *Handler) SetUserCountryQueryClient(userCountryClient client.UserCountry
h.userCountryClient = userCountryClient
}
// SetUserDeviceClient 注入 user-service 设备推送 token client。
func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient) {
h.userDeviceClient = userDeviceClient
}
// SetUserHostClient 注入 user-service host domain 身份查询 client。
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
h.userHostClient = userHostClient
@ -138,6 +145,11 @@ func (h *Handler) SetWalletClient(walletClient client.WalletClient) {
h.walletClient = walletClient
}
// SetMessageInboxClient 注入 activity-service message inbox client。
func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient) {
h.messageClient = messageClient
}
// SetAppConfigReader 注入后台 App 配置读取依赖。
func (h *Handler) SetAppConfigReader(reader appConfigReader) {
h.appConfigReader = reader

View File

@ -0,0 +1,289 @@
package http
import (
"net/http"
"strconv"
"strings"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
)
type messageTabSectionData struct {
Section string `json:"section"`
Title string `json:"title"`
UnreadCount int64 `json:"unread_count"`
Source string `json:"source"`
}
type inboxMessageData struct {
MessageID string `json:"message_id"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Read bool `json:"read"`
SentAtMS int64 `json:"sent_at_ms"`
}
type userProfileBatchData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
RegionID int64 `json:"region_id,omitempty"`
AppCode string `json:"app_code"`
}
// listMessageTabs returns backend unread summary and declares user conversations as Tencent IM SDK owned.
func (h *Handler) listMessageTabs(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.messageClient.ListMessageTabs(request.Context(), &activityv1.ListMessageTabsRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
sections := make([]messageTabSectionData, 0, len(resp.GetSections()))
for _, section := range resp.GetSections() {
sections = append(sections, messageTabSectionData{
Section: section.GetSection(),
Title: section.GetTitle(),
UnreadCount: section.GetUnreadCount(),
Source: section.GetSource(),
})
}
writeOK(writer, request, map[string]any{"sections": sections})
}
// listInboxMessages reads system/activity inbox list; private user conversations stay in Tencent IM SDK.
func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
pageSize, ok := parseMessagePageSize(request.URL.Query().Get("page_size"))
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
section := strings.TrimSpace(request.URL.Query().Get("section"))
resp, err := h.messageClient.ListInboxMessages(request.Context(), &activityv1.ListInboxMessagesRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
Section: section,
PageSize: pageSize,
PageToken: strings.TrimSpace(request.URL.Query().Get("page_token")),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
items := make([]inboxMessageData, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, inboxMessageData{
MessageID: item.GetMessageId(),
Title: item.GetTitle(),
Summary: item.GetSummary(),
Body: item.GetBody(),
IconURL: item.GetIconUrl(),
ImageURL: item.GetImageUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
Read: item.GetRead(),
SentAtMS: item.GetSentAtMs(),
})
}
writeOK(writer, request, map[string]any{"section": resp.GetSection(), "items": items, "next_page_token": resp.GetNextPageToken()})
}
// markInboxSectionRead marks all current visible unread messages in one backend-owned section.
func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
var body struct {
Section string `json:"section"`
}
if !decode(writer, request, &body) {
return
}
resp, err := h.messageClient.MarkInboxSectionRead(request.Context(), &activityv1.MarkInboxSectionReadRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
Section: body.Section,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"section": resp.GetSection(), "read_count": resp.GetReadCount()})
}
// inboxMessageByID handles per-message read and delete routes under /api/v1/messages/{message_id}.
func (h *Handler) inboxMessageByID(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
messageID, readRoute := parseInboxMessagePath(request.URL.Path)
if messageID == "" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
if readRoute {
h.markInboxMessageRead(writer, request, messageID)
return
}
if request.Method == http.MethodDelete {
h.deleteInboxMessage(writer, request, messageID)
return
}
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
}
func (h *Handler) markInboxMessageRead(writer http.ResponseWriter, request *http.Request, messageID string) {
if request.Method != http.MethodPost {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return
}
resp, err := h.messageClient.MarkInboxMessageRead(request.Context(), &activityv1.MarkInboxMessageReadRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MessageId: messageID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "read": resp.GetRead(), "read_at_ms": resp.GetReadAtMs()})
}
func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.Request, messageID string) {
resp, err := h.messageClient.DeleteInboxMessage(request.Context(), &activityv1.DeleteInboxMessageRequest{
Meta: activityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
MessageId: messageID,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "deleted": resp.GetDeleted()})
}
// batchUserProfiles returns only safe profile fields needed to render Tencent IM conversation rows.
func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
userIDs, ok := parseBatchUserIDs(request)
if !ok {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: authRequestMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
writeRPCError(writer, request, err)
return
}
items := make([]userProfileBatchData, 0, len(resp.GetUsers()))
for _, userID := range userIDs {
if user := resp.GetUsers()[userID]; user != nil {
items = append(items, userProfileBatchData{
UserID: strconv.FormatInt(user.GetUserId(), 10),
DisplayUserID: user.GetDisplayUserId(),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
Gender: user.GetGender(),
Country: user.GetCountry(),
RegionID: user.GetRegionId(),
AppCode: user.GetAppCode(),
})
}
}
writeOK(writer, request, map[string]any{"profiles": items})
}
func activityMeta(request *http.Request) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: requestIDFromContext(request.Context()),
Caller: "gateway-service",
GatewayNodeId: "gateway-local",
SentAtMs: time.Now().UnixMilli(),
AppCode: appcode.FromContext(request.Context()),
}
}
func parseMessagePageSize(raw string) (int32, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, true
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || value > 1000 {
return 0, false
}
return int32(value), true
}
func parseInboxMessagePath(path string) (string, bool) {
tail := strings.TrimPrefix(path, apiV1Prefix+"/messages/")
tail = strings.Trim(tail, "/")
if tail == "" || tail == "tabs" || tail == "read-all" {
return "", false
}
if strings.HasSuffix(tail, "/read") {
messageID := strings.TrimSuffix(tail, "/read")
return strings.Trim(messageID, "/"), true
}
if strings.Contains(tail, "/") {
return "", false
}
return tail, false
}
func parseBatchUserIDs(request *http.Request) ([]int64, bool) {
rawValues := append([]string(nil), request.URL.Query()["user_ids"]...)
rawValues = append(rawValues, request.URL.Query()["user_id"]...)
seen := make(map[int64]bool)
result := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
for _, piece := range strings.Split(raw, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
userID, err := strconv.ParseInt(piece, 10, 64)
if err != nil || userID <= 0 {
return nil, false
}
if !seen[userID] {
seen[userID] = true
result = append(result, userID)
}
}
}
if len(result) == 0 || len(result) > 100 {
return nil, false
}
return result, true
}

View File

@ -12,24 +12,6 @@ import (
"hyapp/services/gateway-service/internal/auth"
)
// apiHandler 包装所有 gateway 业务 HTTP API 的公共入口能力。
// 顺序必须是 request_id -> auth -> handler保证鉴权失败也能返回可追踪 request_id。
func apiHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(withAuth(jwtVerifier, next)))
}
// profileAPIHandler 包装必须完成注册资料才能访问的业务 API。
// 三方登录后未完成资料的用户已经有合法 token但不能进入房间、IM、RTC 或付费链路。
func profileAPIHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(withAuth(jwtVerifier, requireCompletedProfile(next))))
}
// publicAPIHandler 包装不需要 access token 的业务 API。
// 登录注册链路仍然有 request_id但不能要求用户已经有 access token。
func publicAPIHandler(next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(next))
}
// apiHandler 包装需要 app 解析和 access token 的业务 API。
func (h *Handler) apiHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
return withRequestID(withAccessLog(h.withResolvedApp(withAuth(jwtVerifier, next))))
@ -124,6 +106,18 @@ func firstHeader(request *http.Request, names ...string) string {
return ""
}
// requireMethod 在业务 handler 之前固定 HTTP method避免 path-only mux 让错误 method 进入命令链路。
func requireMethod(method string, next http.HandlerFunc) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if request.Method != method {
writeError(writer, request, http.StatusMethodNotAllowed, codeInvalidArgument, "invalid argument")
return
}
next(writer, request)
}
}
// requireCompletedProfile 在 gateway 层执行外部准入门禁。
// 这里故意不访问 user-service依赖 access token 快照,避免所有高频房间入口同步打用户服务。
func requireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {

View File

@ -0,0 +1,294 @@
package http
import (
"context"
"net/http"
"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"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
)
const overviewAggregationTimeout = 200 * time.Millisecond
var overviewAssetTypes = []string{"COIN", "DIAMOND", "USD_BALANCE"}
type myOverviewData struct {
Profile userProfileData `json:"profile"`
Wallet overviewWalletData `json:"wallet"`
Roles overviewRolesData `json:"roles"`
Badges overviewBadgesData `json:"badges"`
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"`
}
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"`
}
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 overviewEntryData struct {
Key string `json:"key"`
Title string `json:"title"`
Visible bool `json:"visible"`
BadgeCount int64 `json:"badge_count"`
}
// getMyOverview 聚合 App 我的页首屏摘要gateway 只做 RPC 编排和入口显隐规则,不查数据库。
func (h *Handler) getMyOverview(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
ctx, cancel := context.WithTimeout(request.Context(), overviewAggregationTimeout)
defer cancel()
userID := auth.UserIDFromContext(request.Context())
var wg sync.WaitGroup
var profileResp *userv1.GetUserResponse
var profileErr error
wg.Add(1)
go func() {
defer wg.Done()
profileResp, profileErr = h.userProfileClient.GetUser(ctx, &userv1.GetUserRequest{
Meta: authRequestMeta(request.WithContext(ctx), ""),
UserId: userID,
})
}()
var walletResp *walletv1.GetBalancesResponse
var walletErr error
wg.Add(1)
go func() {
defer wg.Done()
if h.walletClient == nil {
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()),
})
}()
var roles overviewRolesData
var rolesErr error
wg.Add(1)
go func() {
defer wg.Done()
roles, rolesErr = h.getOverviewRoles(ctx, request, userID)
}()
var tabsResp *activityv1.ListMessageTabsResponse
var tabsErr 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)),
UserId: userID,
})
}()
wg.Wait()
if profileErr != nil {
writeRPCError(writer, request, profileErr)
return
}
if profileResp.GetUser() == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
if rolesErr != nil {
roles = overviewRolesData{Unavailable: true}
}
wallet := overviewWallet(walletResp, walletErr)
badges := overviewBadges(tabsResp, tabsErr)
writeOK(writer, request, myOverviewData{
Profile: profileData(profileResp.GetUser(), 0),
Wallet: wallet,
Roles: roles,
Badges: badges,
Entries: overviewEntries(roles),
ServerTimeMS: time.Now().UnixMilli(),
})
}
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
})
}
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)
return overviewRolesData{
IsHost: identity.IsHost,
IsAgent: identity.IsAgent,
IsBD: identity.IsBD,
IsBDLeader: identity.IsBDLeader,
IsCoinSeller: identity.IsCoinSeller,
}, nil
}
func overviewWallet(resp *walletv1.GetBalancesResponse, err error) overviewWalletData {
if err != nil {
return overviewWalletData{
Balances: []assetBalanceData{},
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)
}
return overviewWalletData{
Balances: balances,
RechargeEnabled: true,
DiamondExchangeEnabled: true,
WithdrawEnabled: true,
}
}
func overviewBadges(resp *activityv1.ListMessageTabsResponse, err error) overviewBadgesData {
if err != nil {
return overviewBadgesData{Unavailable: true}
}
var badges overviewBadgesData
for _, section := range resp.GetSections() {
switch section.GetSection() {
case "system":
badges.UnreadSystemMessages = section.GetUnreadCount()
case "activity":
badges.UnreadActivityMessages = section.GetUnreadCount()
}
}
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},
}
}

View File

@ -0,0 +1,218 @@
package http
import (
"encoding/json"
"errors"
"net/http"
"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"},
}
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"},
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
handler.SetMessageInboxClient(messageClient)
data := performOverviewRequest(t, handler, "req-overview")
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 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 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 messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetRequestId() != "req-overview" {
t.Fatalf("message tabs request mismatch: %+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)
}
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 {
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)
}
balances := wallet["balances"].([]any)
if len(balances) != 3 {
t.Fatalf("overview must return three fixed balances, got %+v", balances)
}
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)
}
entries := data["entries"].([]any)
if !overviewEntryVisible(entries, "wallet") ||
!overviewEntryVisible(entries, "host_center") ||
!overviewEntryVisible(entries, "agency_center") ||
!overviewEntryVisible(entries, "bd_center") ||
!overviewEntryVisible(entries, "bd_leader_center") ||
!overviewEntryVisible(entries, "coin_seller_center") ||
overviewEntryVisible(entries, "apply_host") {
t.Fatalf("entry visibility mismatch: %+v", entries)
}
if data["server_time_ms"].(float64) <= 0 {
t.Fatalf("missing server_time_ms: %+v", data)
}
}
func TestGetMyOverviewFillsMissingWalletBalancesForOrdinaryUser(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetWalletClient(&fakeWalletClient{resp: &walletv1.GetBalancesResponse{}})
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)
}
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)
}
}
roles := data["roles"].(map[string]any)
if roles["is_host"] != false || roles["is_bd"] != false || roles["is_coin_seller"] != false {
t.Fatalf("ordinary roles mismatch: %+v", roles)
}
entries := data["entries"].([]any)
if !overviewEntryVisible(entries, "apply_host") || overviewEntryVisible(entries, "host_center") {
t.Fatalf("ordinary entries mismatch: %+v", entries)
}
}
func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
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")})
data := performOverviewRequest(t, handler, "req-overview-degraded")
wallet := data["wallet"].(map[string]any)
if wallet["unavailable"] != true || len(wallet["balances"].([]any)) != 0 {
t.Fatalf("wallet degradation mismatch: %+v", wallet)
}
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)
}
}
func TestGetMyOverviewFailsWhenProfileFails(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{
getErr: xerr.ToGRPCError(xerr.New(xerr.NotFound, "user not found")),
})
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))
request.Header.Set("X-Request-ID", "req-overview-profile-fail")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusNotFound, string(xerr.NotFound), "req-overview-profile-fail")
}
func performOverviewRequest(t *testing.T, handler *Handler, requestID string) map[string]any {
t.Helper()
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))
request.Header.Set("X-Request-ID", requestID)
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())
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode overview response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || response.RequestID != requestID {
t.Fatalf("overview envelope mismatch: %+v", response)
}
return data
}
func overviewEntryVisible(entries []any, key string) bool {
for _, entry := range entries {
item, ok := entry.(map[string]any)
if ok && item["key"] == key {
visible, _ := item["visible"].(bool)
return visible
}
}
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

@ -105,13 +105,13 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error)
func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
switch reason {
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid:
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidSection, xerr.PageTokenInvalid:
return http.StatusBadRequest, string(reason), "invalid argument"
case xerr.AuthFailed:
return http.StatusUnauthorized, string(reason), "authentication failed"
case xerr.Unauthorized, xerr.SessionExpired, xerr.SessionRevoked:
return http.StatusUnauthorized, string(reason), "unauthorized"
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists:
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists, xerr.RequestConflict, xerr.ProducerEventConflict:
return http.StatusConflict, string(reason), "conflict"
case xerr.RoomClosed:
return http.StatusConflict, string(reason), "room closed"
@ -123,7 +123,7 @@ func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
return http.StatusForbidden, codeProfileRequired, "profile required"
case xerr.UserDisabled, xerr.PermissionDenied:
return http.StatusForbidden, string(reason), "permission denied"
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound:
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound, xerr.MessageNotFound, xerr.MessageRecalled:
return http.StatusNotFound, string(reason), "not found"
case xerr.Unavailable:
return http.StatusBadGateway, codeUpstreamError, "upstream service error"

View File

@ -19,6 +19,7 @@ import (
"time"
jwt "github.com/golang-jwt/jwt/v5"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -160,6 +161,7 @@ type fakeUserAuthClient struct {
type fakeUserProfileClient struct {
lastGet *userv1.GetUserRequest
lastBatch *userv1.BatchGetUsersRequest
getRequests []*userv1.GetUserRequest
lastComplete *userv1.CompleteOnboardingRequest
lastUpdate *userv1.UpdateUserProfileRequest
@ -178,9 +180,22 @@ type fakeUserIdentityClient struct {
}
type fakeRoomQueryClient struct {
lastList *roomv1.ListRoomsRequest
resp *roomv1.ListRoomsResponse
err error
lastList *roomv1.ListRoomsRequest
lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest
resp *roomv1.ListRoomsResponse
currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse
err error
currentErr error
snapshotErr error
}
type fakeUserDeviceClient struct {
lastBind *userv1.BindPushTokenRequest
lastDelete *userv1.DeletePushTokenRequest
bindErr error
deleteErr error
}
type fakeUserCountryQueryClient struct {
@ -210,6 +225,20 @@ type fakeWalletClient struct {
transferErr error
}
type fakeMessageInboxClient struct {
lastTabs *activityv1.ListMessageTabsRequest
lastList *activityv1.ListInboxMessagesRequest
lastRead *activityv1.MarkInboxMessageReadRequest
lastReadAll *activityv1.MarkInboxSectionReadRequest
lastDelete *activityv1.DeleteInboxMessageRequest
tabsResp *activityv1.ListMessageTabsResponse
listResp *activityv1.ListInboxMessagesResponse
readResp *activityv1.MarkInboxMessageReadResponse
readAllResp *activityv1.MarkInboxSectionReadResponse
deleteResp *activityv1.DeleteInboxMessageResponse
err error
}
func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
f.lastLoginPassword = req
f.loginPasswordCount++
@ -287,6 +316,24 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
}}, 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()))
for _, userID := range req.GetUserIds() {
users[userID] = &userv1.User{
UserId: userID,
DisplayUserId: strconv.FormatInt(100000+userID, 10),
Username: "user-" + strconv.FormatInt(userID, 10),
Gender: "female",
Country: "US",
Avatar: "https://cdn.example/avatar.png",
RegionId: 1001,
AppCode: req.GetMeta().GetAppCode(),
}
}
return &userv1.BatchGetUsersResponse{Users: users}, nil
}
func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
f.lastComplete = req
if f.completeErr != nil {
@ -396,6 +443,44 @@ func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRooms
return &roomv1.ListRoomsResponse{}, nil
}
func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
f.lastCurrent = req
if f.currentErr != nil {
return nil, f.currentErr
}
if f.currentResp != nil {
return f.currentResp, nil
}
return &roomv1.GetCurrentRoomResponse{}, nil
}
func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
f.lastSnapshot = req
if f.snapshotErr != nil {
return nil, f.snapshotErr
}
if f.snapshotResp != nil {
return f.snapshotResp, nil
}
return &roomv1.GetRoomSnapshotResponse{}, nil
}
func (f *fakeUserDeviceClient) BindPushToken(_ context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
f.lastBind = req
if f.bindErr != nil {
return nil, f.bindErr
}
return &userv1.BindPushTokenResponse{Bound: true, UpdatedAtMs: 1234}, nil
}
func (f *fakeUserDeviceClient) DeletePushToken(_ context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) {
f.lastDelete = req
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &userv1.DeletePushTokenResponse{Deleted: true, UpdatedAtMs: 1235}, nil
}
func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
f.last = req
if f.err != nil {
@ -408,6 +493,61 @@ func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context
return &userv1.ListRegistrationCountriesResponse{}, nil
}
func (f *fakeMessageInboxClient) ListMessageTabs(_ context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
f.lastTabs = req
if f.err != nil {
return nil, f.err
}
if f.tabsResp != nil {
return f.tabsResp, nil
}
return &activityv1.ListMessageTabsResponse{}, nil
}
func (f *fakeMessageInboxClient) ListInboxMessages(_ context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error) {
f.lastList = req
if f.err != nil {
return nil, f.err
}
if f.listResp != nil {
return f.listResp, nil
}
return &activityv1.ListInboxMessagesResponse{Section: req.GetSection()}, nil
}
func (f *fakeMessageInboxClient) MarkInboxMessageRead(_ context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error) {
f.lastRead = req
if f.err != nil {
return nil, f.err
}
if f.readResp != nil {
return f.readResp, nil
}
return &activityv1.MarkInboxMessageReadResponse{MessageId: req.GetMessageId(), Read: true, ReadAtMs: 1234}, nil
}
func (f *fakeMessageInboxClient) MarkInboxSectionRead(_ context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error) {
f.lastReadAll = req
if f.err != nil {
return nil, f.err
}
if f.readAllResp != nil {
return f.readAllResp, nil
}
return &activityv1.MarkInboxSectionReadResponse{Section: req.GetSection(), ReadCount: 2}, nil
}
func (f *fakeMessageInboxClient) DeleteInboxMessage(_ context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
f.lastDelete = req
if f.err != nil {
return nil, f.err
}
if f.deleteResp != nil {
return f.deleteResp, nil
}
return &activityv1.DeleteInboxMessageResponse{MessageId: req.GetMessageId(), Deleted: true}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
if f.hostErr != nil {
@ -841,6 +981,154 @@ func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
}
}
func TestBindPushTokenUsesAuthenticatedUserAndDeviceMeta(t *testing.T) {
deviceClient := &fakeUserDeviceClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserDeviceClient(deviceClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"device_id":"dev-1","push_token":"push-1","provider":"fcm","platform":"android","app_version":"1.2.3","language":"en-US","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/devices/push-token", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-bind-push")
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 deviceClient.lastBind == nil || deviceClient.lastBind.GetUserId() != 42 || deviceClient.lastBind.GetMeta().GetDeviceId() != "dev-1" {
t.Fatalf("push token bind must use authenticated user and device meta: %+v", deviceClient.lastBind)
}
if deviceClient.lastBind.GetPushToken() != "push-1" || deviceClient.lastBind.GetPlatform() != "android" || deviceClient.lastBind.GetProvider() != "fcm" {
t.Fatalf("push token bind fields mismatch: %+v", deviceClient.lastBind)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode bind response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if !ok || data["bound"] != true || data["updated_at_ms"].(float64) != 1234 {
t.Fatalf("push token response must keep explicit fields: %+v", response.Data)
}
}
func TestDeletePushTokenUsesAuthenticatedUser(t *testing.T) {
deviceClient := &fakeUserDeviceClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserDeviceClient(deviceClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"device_id":"dev-1","push_token":"push-1"}`)
request := httptest.NewRequest(http.MethodDelete, "/api/v1/devices/push-token", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-delete-push")
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 deviceClient.lastDelete == nil || deviceClient.lastDelete.GetUserId() != 42 || deviceClient.lastDelete.GetDeviceId() != "dev-1" || deviceClient.lastDelete.GetPushToken() != "push-1" {
t.Fatalf("push token delete fields mismatch: %+v", deviceClient.lastDelete)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode delete response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if !ok || data["deleted"] != true || data["updated_at_ms"].(float64) != 1235 {
t.Fatalf("push token delete response must keep explicit fields: %+v", response.Data)
}
}
func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
queryClient := &fakeRoomQueryClient{currentResp: &roomv1.GetCurrentRoomResponse{
HasCurrentRoom: true,
RoomId: "room-current",
RoomVersion: 9,
Role: "audience",
NeedJoinImGroup: true,
ServerTimeMs: 123456,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/current?user_id=999", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-current-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.lastCurrent == nil {
t.Fatal("current room query was not sent")
}
if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != "req-current-room" {
t.Fatalf("current room must use authenticated user and request meta: %+v", queryClient.lastCurrent)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode current room response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if !ok || data["has_current_room"] != true || data["need_rtc_token"] != false || data["room_id"] != "room-current" {
t.Fatalf("current room response must keep explicit booleans: %+v", response.Data)
}
}
func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
Room: &roomv1.RoomSnapshot{
RoomId: "room-snapshot",
OwnerUserId: 42,
Status: "active",
Version: 7,
},
ServerTimeMs: 123456,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room-snapshot&viewer_user_id=999", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-snapshot")
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.lastSnapshot == nil {
t.Fatal("room snapshot query was not sent")
}
if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != "req-room-snapshot" {
t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot)
}
}
func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
queryClient := &fakeRoomQueryClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room:bad", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-room-snapshot-invalid")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-room-snapshot-invalid")
if queryClient.lastSnapshot != nil {
t.Fatalf("invalid room_id must not reach room-service: %+v", queryClient.lastSnapshot)
}
}
func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T) {
countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{
Countries: []*userv1.Country{
@ -942,6 +1230,34 @@ func TestListH5LinksRequiresConfigReader(t *testing.T) {
assertEnvelope(t, recorder, http.StatusBadGateway, codeUpstreamError, "req-h5-links-missing")
}
func TestAppBootstrapIsPublicAndLightweight(t *testing.T) {
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil)
request.Header.Set("X-Request-ID", "req-bootstrap")
request.Header.Set("X-App-Code", "lalu")
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())
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
if data["app_code"] != "lalu" || data["server_time_ms"].(float64) <= 0 || data["force_upgrade"] != false || data["maintenance"] != false {
t.Fatalf("bootstrap base fields mismatch: %+v", data)
}
if _, exists := data["banners"]; exists {
t.Fatalf("bootstrap must not return large config payloads: %+v", data)
}
}
func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetAppConfigReader(appconfig.StaticReader{Banners: []appconfig.Banner{
@ -1356,6 +1672,128 @@ func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
}
}
func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
messageClient := &fakeMessageInboxClient{tabsResp: &activityv1.ListMessageTabsResponse{Sections: []*activityv1.MessageTabSection{
{Section: "user", Title: "用户", UnreadCount: 0, Source: "tencent_im_sdk"},
{Section: "system", Title: "系统", UnreadCount: 2, Source: "backend"},
{Section: "activity", Title: "活动", UnreadCount: 1, Source: "backend"},
}}, listResp: &activityv1.ListInboxMessagesResponse{
Section: "system",
Items: []*activityv1.InboxMessage{{
MessageId: "msg-1",
Title: "提现审核通过",
Summary: "你的提现申请已通过。",
ActionType: "wallet_withdraw_detail",
ActionParam: "withdrawal_id=wd_01",
SentAtMs: 1777996800000,
}},
NextPageToken: "cursor-2",
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetMessageInboxClient(messageClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/messages/tabs", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-message-tabs")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetAppCode() != "lalu" || messageClient.lastTabs.GetMeta().GetRequestId() != "req-message-tabs" {
t.Fatalf("message tabs request mismatch: %+v", messageClient.lastTabs)
}
request = httptest.NewRequest(http.MethodGet, "/api/v1/messages?section=system&page_size=20&page_token=cursor-1", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-message-list")
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if messageClient.lastList == nil || messageClient.lastList.GetUserId() != 42 || messageClient.lastList.GetSection() != "system" || messageClient.lastList.GetPageSize() != 20 || messageClient.lastList.GetPageToken() != "cursor-1" {
t.Fatalf("message list request mismatch: %+v", messageClient.lastList)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode list response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != codeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
t.Fatalf("message list envelope mismatch: %+v", response)
}
}
func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
messageClient := &fakeMessageInboxClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetMessageInboxClient(messageClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/read-all", bytes.NewReader([]byte(`{"section":"activity"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-message-read-all")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || messageClient.lastReadAll == nil || messageClient.lastReadAll.GetUserId() != 42 || messageClient.lastReadAll.GetSection() != "activity" {
t.Fatalf("read-all mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastReadAll)
}
request = httptest.NewRequest(http.MethodPost, "/api/v1/messages/msg-1/read", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-message-read")
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || messageClient.lastRead == nil || messageClient.lastRead.GetUserId() != 42 || messageClient.lastRead.GetMessageId() != "msg-1" {
t.Fatalf("read mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastRead)
}
request = httptest.NewRequest(http.MethodDelete, "/api/v1/messages/msg-1", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-message-delete")
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || messageClient.lastDelete == nil || messageClient.lastDelete.GetUserId() != 42 || messageClient.lastDelete.GetMessageId() != "msg-1" {
t.Fatalf("delete mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastDelete)
}
}
func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) {
profileClient := &fakeUserProfileClient{}
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/profiles:batch?user_ids=42,43", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 7))
request.Header.Set("X-Request-ID", "req-profile-batch")
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 profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != "req-profile-batch" || len(profileClient.lastBatch.GetUserIds()) != 2 {
t.Fatalf("batch user request mismatch: %+v", profileClient.lastBatch)
}
var response responseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode profile batch failed: %v", err)
}
data, ok := response.Data.(map[string]any)
profiles, profilesOK := data["profiles"].([]any)
if response.Code != codeOK || !ok || !profilesOK || len(profiles) != 2 {
t.Fatalf("profile batch response mismatch: %+v", response)
}
first, ok := profiles[0].(map[string]any)
if !ok || first["user_id"] != "42" || first["display_user_id"] == "" || first["username"] == "" || first["app_code"] != "lalu" {
t.Fatalf("profile batch item mismatch: %+v", first)
}
if _, exists := first["birth"]; exists {
t.Fatalf("profile batch must not expose birth or other non-conversation fields: %+v", first)
}
}
func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{
Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}},
@ -1600,6 +2038,12 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
{name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`},
{name: "message_tabs", method: http.MethodGet, path: "/api/v1/messages/tabs"},
{name: "message_list", method: http.MethodGet, path: "/api/v1/messages?section=system"},
{name: "message_read", method: http.MethodPost, path: "/api/v1/messages/msg-1/read"},
{name: "message_read_all", method: http.MethodPost, path: "/api/v1/messages/read-all", body: `{"section":"system"}`},
{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
}
for _, test := range tests {
@ -1627,6 +2071,51 @@ func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testin
}
}
func TestVoiceRoomRoutesRejectWrongHTTPMethod(t *testing.T) {
roomClient := &fakeRoomClient{}
queryClient := &fakeRoomQueryClient{}
guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}}
handler := NewHandlerWithConfig(roomClient, guardClient, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
SecretKey: "secret",
UserSigTTL: time.Hour,
})
handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour})
handler.SetRoomQueryClient(queryClient)
router := handler.Routes(auth.NewVerifier("secret"))
tests := []struct {
name string
method string
path string
body string
}{
{name: "rooms_list", method: http.MethodPost, path: "/api/v1/rooms"},
{name: "current_room", method: http.MethodPost, path: "/api/v1/rooms/current"},
{name: "join_room", method: http.MethodGet, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
{name: "heartbeat", method: http.MethodGet, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
{name: "im_usersig", method: http.MethodPost, path: "/api/v1/im/usersig"},
{name: "rtc_token", method: http.MethodGet, path: "/api/v1/rtc/token"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-method-guard")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusMethodNotAllowed, codeInvalidArgument, "req-method-guard")
})
}
if roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || queryClient.lastList != nil || queryClient.lastCurrent != nil || guardClient.lastPresence != nil {
t.Fatalf("method guard must stop voice-room calls before downstream clients: room=%+v query=%+v guard=%+v", roomClient, queryClient, guardClient)
}
}
func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) {
uploader := &fakeObjectUploader{}
handler := NewHandler(&fakeRoomClient{})

View File

@ -8,6 +8,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
"hyapp/services/gateway-service/internal/auth"
)
@ -72,6 +73,78 @@ func parseRoomListLimit(raw string) (int32, bool) {
return int32(value), true
}
// getCurrentRoom 查询当前登录用户是否仍有可恢复房间。
// gateway 只传鉴权 user_idpresence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。
func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
resp, err := h.roomQueryClient.GetCurrentRoom(request.Context(), &roomv1.GetCurrentRoomRequest{
Meta: meta(request, "", ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
writeRPCError(writer, request, err)
return
}
writeOK(writer, request, currentRoomDataFromProto(resp))
}
// currentRoomData 是 gateway 的外部 JSON 契约,显式保留 false/0 字段。
// 不能直接把 protobuf response 写出去,否则 proto3 的 omitempty 会让 has_current_room=false 消失。
type currentRoomData struct {
HasCurrentRoom bool `json:"has_current_room"`
RoomID string `json:"room_id"`
RoomVersion int64 `json:"room_version"`
Role string `json:"role"`
MicSessionID string `json:"mic_session_id"`
PublishState string `json:"publish_state"`
NeedJoinIMGroup bool `json:"need_join_im_group"`
NeedRTCToken bool `json:"need_rtc_token"`
ServerTimeMS int64 `json:"server_time_ms"`
}
func currentRoomDataFromProto(resp *roomv1.GetCurrentRoomResponse) currentRoomData {
if resp == nil {
return currentRoomData{}
}
return currentRoomData{
HasCurrentRoom: resp.GetHasCurrentRoom(),
RoomID: resp.GetRoomId(),
RoomVersion: resp.GetRoomVersion(),
Role: resp.GetRole(),
MicSessionID: resp.GetMicSessionId(),
PublishState: resp.GetPublishState(),
NeedJoinIMGroup: resp.GetNeedJoinImGroup(),
NeedRTCToken: resp.GetNeedRtcToken(),
ServerTimeMS: resp.GetServerTimeMs(),
}
}
// getRoomSnapshot 主动拉取房间页完整快照。
// 该接口只读 Room Cell/snapshot不刷新 heartbeat也不隐式 JoinRoom。
func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Request) {
if h.roomQueryClient == nil {
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
return
}
roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
if !roomid.ValidStringID(roomID) {
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
return
}
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
Meta: meta(request, roomID, ""),
RoomId: roomID,
ViewerUserId: auth.UserIDFromContext(request.Context()),
})
write(writer, request, resp, err)
}
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
var body struct {

View File

@ -20,18 +20,22 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
mux.Handle(apiV1Prefix+"/auth/logout", h.publicAPIHandler(h.logout))
mux.Handle(apiV1Prefix+"/countries", h.publicAPIHandler(h.listRegistrationCountries))
mux.Handle(apiV1Prefix+"/app/bootstrap", h.publicAPIHandler(h.getAppBootstrap))
mux.Handle(apiV1Prefix+"/app/h5-links", h.publicAPIHandler(h.listH5Links))
mux.Handle(apiV1Prefix+"/app/banners", h.publicAPIHandler(h.listAppBanners))
mux.Handle(apiV1Prefix+"/resources", h.publicAPIHandler(h.listResources))
mux.Handle(apiV1Prefix+"/resource-groups/", h.publicAPIHandler(h.getResourceGroup))
mux.Handle(apiV1Prefix+"/gifts", h.publicAPIHandler(h.listGifts))
mux.Handle(apiV1Prefix+"/im/usersig", h.profileAPIHandler(jwtVerifier, h.issueTencentIMUserSig))
mux.Handle(apiV1Prefix+"/rtc/token", h.profileAPIHandler(jwtVerifier, h.issueTencentRTCToken))
mux.Handle(apiV1Prefix+"/im/usersig", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodGet, h.issueTencentIMUserSig)))
mux.Handle(apiV1Prefix+"/rtc/token", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.issueTencentRTCToken)))
mux.Handle(apiV1Prefix+"/tencent-im/callback", h.publicAPIHandler(h.handleTencentIMCallback))
mux.Handle(apiV1Prefix+"/files/upload", h.apiHandler(jwtVerifier, h.uploadFile))
mux.Handle(apiV1Prefix+"/files/avatar/upload", h.apiHandler(jwtVerifier, h.uploadAvatar))
mux.Handle(apiV1Prefix+"/devices/push-token", h.profileAPIHandler(jwtVerifier, h.handlePushToken))
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))
@ -43,24 +47,30 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
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+"/rooms", h.profileAPIHandler(jwtVerifier, h.listRooms))
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, h.createRoom))
mux.Handle(apiV1Prefix+"/rooms/join", h.profileAPIHandler(jwtVerifier, h.joinRoom))
mux.Handle(apiV1Prefix+"/rooms/heartbeat", h.profileAPIHandler(jwtVerifier, h.roomHeartbeat))
mux.Handle(apiV1Prefix+"/rooms/leave", h.profileAPIHandler(jwtVerifier, h.leaveRoom))
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, h.micUp))
mux.Handle(apiV1Prefix+"/rooms/mic/down", h.profileAPIHandler(jwtVerifier, h.micDown))
mux.Handle(apiV1Prefix+"/rooms/mic/change", h.profileAPIHandler(jwtVerifier, h.changeMicSeat))
mux.Handle(apiV1Prefix+"/rooms/mic/publishing/confirm", h.profileAPIHandler(jwtVerifier, h.confirmMicPublishing))
mux.Handle(apiV1Prefix+"/rooms/mic/lock", h.profileAPIHandler(jwtVerifier, h.setMicSeatLock))
mux.Handle(apiV1Prefix+"/rooms/chat/enabled", h.profileAPIHandler(jwtVerifier, h.setChatEnabled))
mux.Handle(apiV1Prefix+"/rooms/admin/set", h.profileAPIHandler(jwtVerifier, h.setRoomAdmin))
mux.Handle(apiV1Prefix+"/rooms/host/transfer", h.profileAPIHandler(jwtVerifier, h.transferRoomHost))
mux.Handle(apiV1Prefix+"/rooms/user/mute", h.profileAPIHandler(jwtVerifier, h.muteUser))
mux.Handle(apiV1Prefix+"/rooms/user/kick", h.profileAPIHandler(jwtVerifier, h.kickUser))
mux.Handle(apiV1Prefix+"/rooms/user/unban", h.profileAPIHandler(jwtVerifier, h.unbanUser))
mux.Handle(apiV1Prefix+"/rooms/gift/send", h.profileAPIHandler(jwtVerifier, h.sendGift))
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)))
mux.Handle(apiV1Prefix+"/rooms/create", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.createRoom)))
mux.Handle(apiV1Prefix+"/rooms/join", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.joinRoom)))
mux.Handle(apiV1Prefix+"/rooms/heartbeat", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.roomHeartbeat)))
mux.Handle(apiV1Prefix+"/rooms/leave", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.leaveRoom)))
mux.Handle(apiV1Prefix+"/rooms/mic/up", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.micUp)))
mux.Handle(apiV1Prefix+"/rooms/mic/down", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.micDown)))
mux.Handle(apiV1Prefix+"/rooms/mic/change", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.changeMicSeat)))
mux.Handle(apiV1Prefix+"/rooms/mic/publishing/confirm", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.confirmMicPublishing)))
mux.Handle(apiV1Prefix+"/rooms/mic/lock", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setMicSeatLock)))
mux.Handle(apiV1Prefix+"/rooms/chat/enabled", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setChatEnabled)))
mux.Handle(apiV1Prefix+"/rooms/admin/set", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.setRoomAdmin)))
mux.Handle(apiV1Prefix+"/rooms/host/transfer", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.transferRoomHost)))
mux.Handle(apiV1Prefix+"/rooms/user/mute", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.muteUser)))
mux.Handle(apiV1Prefix+"/rooms/user/kick", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.kickUser)))
mux.Handle(apiV1Prefix+"/rooms/user/unban", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.unbanUser)))
mux.Handle(apiV1Prefix+"/rooms/gift/send", h.profileAPIHandler(jwtVerifier, requireMethod(http.MethodPost, h.sendGift)))
mux.Handle(apiV1Prefix+"/tencent-rtc/callback", h.publicAPIHandler(h.handleTencentRTCCallback))
mux.Handle(apiV1Prefix+"/messages/tabs", h.profileAPIHandler(jwtVerifier, h.listMessageTabs))
mux.Handle(apiV1Prefix+"/messages/read-all", h.profileAPIHandler(jwtVerifier, h.markInboxSectionRead))
mux.Handle(apiV1Prefix+"/messages/", h.profileAPIHandler(jwtVerifier, h.inboxMessageByID))
mux.Handle(apiV1Prefix+"/messages", h.profileAPIHandler(jwtVerifier, h.listInboxMessages))
mux.Handle(apiV1Prefix+"/wallet/me/balances", h.profileAPIHandler(jwtVerifier, h.getMyBalances))
mux.Handle(apiV1Prefix+"/wallet/coin-seller/transfer", h.profileAPIHandler(jwtVerifier, h.transferCoinFromSeller))

View File

@ -49,6 +49,22 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS room_user_presence (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL,
publish_state VARCHAR(32) NOT NULL DEFAULT '',
mic_session_id VARCHAR(128) NOT NULL DEFAULT '',
room_version BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
last_seen_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id),
KEY idx_room_user_presence_room (app_code, room_id, status),
KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,

View File

@ -103,6 +103,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
if err != nil {
return nil, err
}
// CreateRoom 不经过 mutateRoom 管线,幂等重试也要补齐恢复读模型,避免旧请求成功但投影缺失。
s.projectRoomPresenceBestEffort(ctx, snapshot)
return &roomv1.CreateRoomResponse{
Result: commandResult(false, snapshot.GetVersion(), s.clock.Now()),
@ -211,6 +213,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
return nil, err
}
s.projectRoomListBestEffort(ctx, snapshot)
// owner 创建房间后已经是在线成员,必须立即投影到用户当前房间读模型。
s.projectRoomPresenceBestEffort(ctx, snapshot)
// 持久化成功后再安装内存 Cell避免内存有房间但恢复来源不完整。
s.installCell(ctx, cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))

View File

@ -0,0 +1,152 @@
package service
import (
"context"
"log"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state"
)
const (
roomPresenceStatusActive = "active"
roomPresenceStatusLeft = "left"
)
// GetCurrentRoom 返回当前用户可恢复房间的只读探测结果。
// 该方法先查用户级 presence 读模型,再用 Room Cell 快照二次校验,避免读模型滞后导致恢复已关闭或已踢出的房间。
func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
userID := req.GetUserId()
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
serverTimeMS := s.clock.Now().UnixMilli()
presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, userID)
if err != nil {
return nil, err
}
if !exists {
return emptyCurrentRoomResponse(serverTimeMS), nil
}
snapshot, err := s.currentSnapshot(ctx, presence.RoomID)
if err != nil {
return nil, err
}
if !currentRoomSnapshotRestorable(snapshot, userID) {
// 查询语义必须保持只读;读模型滞后由后续命令投影或专项补偿修复。
return emptyCurrentRoomResponse(serverTimeMS), nil
}
roomUser := findProtoUser(snapshot, userID)
seat := protoSeatByUser(snapshot, userID)
role := presence.Role
if roomUser != nil {
role = roomUser.GetRole()
}
publishState := ""
micSessionID := ""
needRTCToken := false
if seat != nil {
publishState = seat.GetPublishState()
micSessionID = seat.GetMicSessionId()
needRTCToken = publishState == state.MicPublishPending || publishState == state.MicPublishPublishing
}
return &roomv1.GetCurrentRoomResponse{
HasCurrentRoom: true,
RoomId: snapshot.GetRoomId(),
RoomVersion: snapshot.GetVersion(),
Role: role,
MicSessionId: micSessionID,
PublishState: publishState,
NeedJoinImGroup: true,
NeedRtcToken: needRTCToken,
ServerTimeMs: serverTimeMS,
}, nil
}
func emptyCurrentRoomResponse(serverTimeMS int64) *roomv1.GetCurrentRoomResponse {
return &roomv1.GetCurrentRoomResponse{ServerTimeMs: serverTimeMS}
}
func currentRoomSnapshotRestorable(snapshot *roomv1.RoomSnapshot, userID int64) bool {
if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive {
return false
}
if containsUserID(snapshot.GetBanUserIds(), userID) {
return false
}
return findProtoUser(snapshot, userID) != nil
}
// projectRoomPresenceBestEffort 用最新快照刷新“用户当前房间”读模型。
// 读模型失败不能影响命令成功语义,但必须记录日志,避免恢复入口长期缺失无人感知。
func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
projection := roomPresenceProjectionFromSnapshot(snapshot, s.clock.Now().UnixMilli())
if projection.RoomID == "" {
return
}
if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil {
log.Printf("component=room_presence_projector room_id=%s version=%d status=project_failed error=%q", projection.RoomID, projection.RoomVersion, err.Error())
}
}
func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot {
if snapshot == nil || snapshot.GetRoomId() == "" {
return RoomPresenceSnapshot{}
}
seatsByUser := make(map[int64]*roomv1.SeatState, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
seatsByUser[seat.GetUserId()] = seat
}
}
users := make([]RoomPresence, 0, len(snapshot.GetOnlineUsers()))
if snapshot.GetStatus() == state.RoomStatusActive {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() <= 0 {
continue
}
entry := RoomPresence{
AppCode: snapshot.GetAppCode(),
UserID: user.GetUserId(),
RoomID: snapshot.GetRoomId(),
Role: user.GetRole(),
RoomVersion: snapshot.GetVersion(),
Status: roomPresenceStatusActive,
LastSeenAtMS: user.GetLastSeenAtMs(),
UpdatedAtMS: updatedAtMS,
}
if seat := seatsByUser[user.GetUserId()]; seat != nil {
entry.PublishState = seat.GetPublishState()
entry.MicSessionID = seat.GetMicSessionId()
}
users = append(users, entry)
}
}
return RoomPresenceSnapshot{
AppCode: snapshot.GetAppCode(),
RoomID: snapshot.GetRoomId(),
RoomVersion: snapshot.GetVersion(),
RoomStatus: snapshot.GetStatus(),
Users: users,
UpdatedAtMS: updatedAtMS,
}
}
func containsUserID(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@ -57,11 +57,13 @@ func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string {
}
func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, now time.Time) (router.Lease, bool, error) {
lease, exists, err := s.directory.Lookup(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID))
if err != nil || !exists {
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, now, s.leaseTTL)
if err != nil {
return router.Lease{}, false, err
}
if lease.NodeID != s.nodeID || !lease.ValidAt(now) {
// 后台 worker 只处理自己仍然拥有或可安全续租的 Cell。
// 如果其他节点已经接管并持有有效 lease当前节点必须跳过避免旧内存态覆盖新 owner。
return router.Lease{}, false, nil
}

View File

@ -133,6 +133,8 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
if result.applied {
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
s.projectRoomListBestEffort(ctx, result.snapshot)
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
}
if result.applied && result.syncEvent != nil {

View File

@ -116,6 +116,31 @@ type RoomListEntry struct {
UpdatedAtMS int64
}
// RoomPresence 是当前用户可恢复房间的轻量读模型。
// 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。
type RoomPresence struct {
// AppCode 是 presence 所属 App和 user_id 一起限定用户当前房间。
AppCode string
// UserID 是业务 presence 所属用户。
UserID int64
// RoomID 是用户当前仍在的房间status=left 时只保留排障参考。
RoomID string
// Role 是房间内轻量角色,来自 RoomUser.role。
Role string
// PublishState 是用户当前麦位发流状态,非上麦用户为空。
PublishState string
// MicSessionID 是当前麦位发流会话,非上麦用户为空。
MicSessionID string
// RoomVersion 是投影来自的房间版本。
RoomVersion int64
// Status 表示该用户 presence 是否仍 active。
Status string
// LastSeenAtMS 是用户在 Room Cell 内的最后业务心跳时间。
LastSeenAtMS int64
// UpdatedAtMS 是投影写入时间。
UpdatedAtMS int64
}
// RoomListQuery 是 room-service 房间列表读模型查询条件。
type RoomListQuery struct {
// AppCode 是列表查询所属 App通常来自 gateway RequestMeta。
@ -172,4 +197,18 @@ type Repository interface {
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
// ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。
ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error)
// ProjectRoomPresence 用最新快照刷新用户当前房间读模型;失败不应破坏 Room Cell 已提交状态。
ProjectRoomPresence(ctx context.Context, snapshot RoomPresenceSnapshot) error
// GetCurrentRoomPresence 查询用户当前 active 房间 presence不扫描房间列表或快照。
GetCurrentRoomPresence(ctx context.Context, userID int64) (RoomPresence, bool, error)
}
// RoomPresenceSnapshot 是 repository 投影接口的稳定入参,避免存储层反向依赖 protobuf。
type RoomPresenceSnapshot struct {
AppCode string
RoomID string
RoomVersion int64
RoomStatus string
Users []RoomPresence
UpdatedAtMS int64
}

View File

@ -189,50 +189,18 @@ func (s *Service) applyRTCAudioStopped(now time.Time, current *state.RoomState,
func (s *Service) applyRTCRoomExited(now time.Time, current *state.RoomState, cmd command.ApplyRTCEvent) (mutationResult, []outbox.Record, error) {
if user, exists := current.OnlineUsers[cmd.TargetUserID]; exists && user.LastSeenAtMS > cmd.EventTimeMS {
// 腾讯可能重试或延迟投递旧退房事件;如果业务 presence 已被更新到事件之后,保留当前重连状态。
// 腾讯可能重试或延迟投递旧退房事件;如果业务 presence 已被更新到事件之后,保留当前重连和麦位状态。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
_, inRoom := current.OnlineUsers[cmd.TargetUserID]
seat, onSeat := current.SeatByUser(cmd.TargetUserID)
if onSeat && !rtcStopEventMatchesCurrentMicSession(seat, cmd.EventTimeMS, s.micPublishTimeout) {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
}
if !inRoom && !onSeat {
_, onSeat := current.SeatByUser(cmd.TargetUserID)
if !onSeat {
// RTC 退房只代表媒体频道状态,不拥有 room-service 业务 presence。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
delete(current.OnlineUsers, cmd.TargetUserID)
if onSeat {
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
}
current.Version++
leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
UserId: cmd.TargetUserID,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: seat.SeatNo,
syncEvent: &tencentim.RoomEvent{
EventID: leftEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_left",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
RoomVersion: current.Version,
Attributes: map[string]string{
"reason": cmd.Reason,
"source": cmd.Source,
"external_event_id": cmd.ExternalEventID,
},
},
}, []outbox.Record{leftEvent}, nil
// 用户仍可能停留在房间页继续重连;这里只释放当前 mic_session业务离房只能由 LeaveRoom、kick、close 或 stale worker 触发。
return s.applyRTCAudioStopped(now, current, cmd)
}
func rtcEventMatchesCurrentMicSession(seat state.MicSeat, eventTimeMS int64, timeout time.Duration) bool {

View File

@ -332,6 +332,144 @@ func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
}
}
func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-current-presence"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Current Room",
}); 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)
}
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
if err != nil {
t.Fatalf("MicUp failed: %v", err)
}
current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
UserId: 2,
})
if err != nil {
t.Fatalf("GetCurrentRoom failed: %v", err)
}
if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "audience" {
t.Fatalf("current room response mismatch: %+v", current)
}
if current.GetPublishState() != "pending_publish" || current.GetMicSessionId() != micResp.GetMicSessionId() || !current.GetNeedRtcToken() || !current.GetNeedJoinImGroup() {
t.Fatalf("current mic restore fields mismatch: %+v", current)
}
}
func TestGetCurrentRoomReturnsOwnerAfterCreate(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-current-owner-after-create"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Current Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
UserId: 1,
})
if err != nil {
t.Fatalf("GetCurrentRoom failed: %v", err)
}
if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "owner" || !current.GetNeedJoinImGroup() {
t.Fatalf("owner should recover room immediately after CreateRoom: %+v", current)
}
if current.GetNeedRtcToken() || current.GetPublishState() != "" || current.GetMicSessionId() != "" {
t.Fatalf("owner not on mic should not require RTC restore: %+v", current)
}
}
func TestGetCurrentRoomReturnsEmptyAfterLeave(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-current-left"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Current Room",
}); 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)
}
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
t.Fatalf("LeaveRoom failed: %v", err)
}
current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
UserId: 2,
})
if err != nil {
t.Fatalf("GetCurrentRoom failed: %v", err)
}
if current.GetHasCurrentRoom() || current.GetRoomId() != "" {
t.Fatalf("left user should not have current room: %+v", current)
}
}
func TestGetRoomSnapshotRequiresViewerPresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
roomID := "room-snapshot-read"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, 1),
SeatCount: 4,
Mode: "voice",
RoomName: "Snapshot Room",
}); err != nil {
t.Fatalf("CreateRoom failed: %v", err)
}
if _, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
RoomId: roomID,
ViewerUserId: 2,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("non-presence viewer should not read full snapshot, got %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.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
RoomId: roomID,
ViewerUserId: 2,
})
if err != nil {
t.Fatalf("GetRoomSnapshot failed: %v", err)
}
if resp.GetRoom().GetRoomId() != roomID || resp.GetRoom().GetVersion() == 0 || findRoomUser(resp.GetRoom(), 2) == nil {
t.Fatalf("snapshot response mismatch: %+v", resp.GetRoom())
}
}
func TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
@ -648,7 +786,7 @@ func TestAudienceCanChangeOwnMicSeat(t *testing.T) {
}
}
func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
func TestApplyRTCEventConfirmsAudioAndClearsMicOnStopOrExit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
@ -716,14 +854,14 @@ func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
if err != nil {
t.Fatalf("ApplyRTCEvent room_exited failed: %v", err)
}
if user := findRoomUser(exitResp.GetRoom(), 3); user != nil {
t.Fatalf("RTC room_exited should remove business presence: user=%+v", user)
if user := findRoomUser(exitResp.GetRoom(), 3); user == nil {
t.Fatalf("RTC room_exited must not remove business presence")
}
if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 0 {
t.Fatalf("RTC room_exited should release occupied mic: seat=%+v", seat)
}
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_user_left" {
t.Fatalf("RTC room_exited should publish room_user_left: %+v", syncPublisher.events)
if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_mic_down" {
t.Fatalf("RTC room_exited should publish room_mic_down: %+v", syncPublisher.events)
}
}
@ -790,8 +928,9 @@ func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
directory := router.NewMemoryDirectory()
syncPublisher := &fakeSyncPublisher{}
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
roomID := "room-mic-publish-timeout"
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
@ -805,9 +944,8 @@ func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
t.Fatalf("MicUp failed: %v", err)
}
usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs())
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
t.Fatalf("RoomHeartbeat owner before mic timeout sweep failed: %v", err)
}
// 真实配置里 lease_ttl 可能短于 publish timeoutworker 必须能在无其他 owner 时续租并执行超时下麦。
directory.ForceExpire(roomRouteKeyForTest(roomID))
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
}

View File

@ -0,0 +1,55 @@
package service
import (
"context"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state"
)
// GetRoomSnapshot 返回房间页主动同步所需的完整 Room Cell 快照。
// 该查询只读,不刷新 presence、不写 command log也不隐式 JoinRoom。
func (s *Service) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
roomID := req.GetRoomId()
viewerUserID := req.GetViewerUserId()
if !roomid.ValidStringID(roomID) {
// 快照查询同样受腾讯 IM GroupID 和 RTC strRoomId 格式约束,避免脏 room_id 进入恢复路径。
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
if viewerUserID <= 0 {
// viewer_user_id 必须来自 gateway 鉴权room-service 不接受匿名读取完整 presence。
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
closed, err := s.isRoomClosed(ctx, roomID)
if err != nil {
return nil, err
}
if closed {
return nil, xerr.New(xerr.RoomClosed, "room closed")
}
snapshot, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return nil, err
}
if snapshot == nil || snapshot.GetRoomId() == "" {
return nil, xerr.New(xerr.NotFound, "room not found")
}
if snapshot.GetStatus() != state.RoomStatusActive {
return nil, xerr.New(xerr.RoomClosed, "room closed")
}
if containsUserID(snapshot.GetBanUserIds(), viewerUserID) || findProtoUser(snapshot, viewerUserID) == nil {
// 完整快照包含在线用户、麦位和房管集合,必须要求 viewer 仍在业务 presence 内。
return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
}
return &roomv1.GetRoomSnapshotResponse{
Room: snapshot,
ServerTimeMs: s.clock.Now().UnixMilli(),
}, nil
}

View File

@ -110,6 +110,21 @@ func (r *Repository) Migrate(ctx context.Context) error {
KEY idx_room_list_region_new (app_code, visible_region_id, status, created_at_ms DESC, room_id),
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_user_presence (
app_code VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
room_id VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL,
publish_state VARCHAR(32) NOT NULL DEFAULT '',
mic_session_id VARCHAR(128) NOT NULL DEFAULT '',
room_version BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
last_seen_at_ms BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, user_id),
KEY idx_room_user_presence_room (app_code, room_id, status),
KEY idx_room_user_presence_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_snapshots (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
@ -154,6 +169,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
) 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`,
@ -819,6 +835,110 @@ 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
}
// ProjectRoomPresence 用最新 RoomSnapshot 投影用户当前房间读模型。
func (r *Repository) ProjectRoomPresence(ctx context.Context, snapshot roomservice.RoomPresenceSnapshot) error {
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
if strings.TrimSpace(snapshot.RoomID) == "" {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 先把该房间上一版 active presence 全部置为 left再把本次快照里的在线用户 upsert 回 active。
// 这样 Join/Leave/Kick/Close 的投影逻辑统一,避免按命令类型维护多份分支。
if _, err := tx.ExecContext(ctx,
`UPDATE room_user_presence
SET status = ?, publish_state = '', mic_session_id = '', room_version = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND status = ?`,
roomPresenceStatusLeftSQL,
snapshot.RoomVersion,
snapshot.UpdatedAtMS,
appCode,
snapshot.RoomID,
roomPresenceStatusActiveSQL,
); err != nil {
_ = tx.Rollback()
return err
}
for _, user := range snapshot.Users {
if user.UserID <= 0 {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_presence (
app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
room_id = VALUES(room_id),
role = VALUES(role),
publish_state = VALUES(publish_state),
mic_session_id = VALUES(mic_session_id),
room_version = VALUES(room_version),
status = VALUES(status),
last_seen_at_ms = VALUES(last_seen_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
user.UserID,
snapshot.RoomID,
user.Role,
user.PublishState,
user.MicSessionID,
snapshot.RoomVersion,
roomPresenceStatusActiveSQL,
user.LastSeenAtMS,
snapshot.UpdatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
// GetCurrentRoomPresence 查询用户当前 active 房间,不扫描发现页列表。
func (r *Repository) GetCurrentRoomPresence(ctx context.Context, userID int64) (roomservice.RoomPresence, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, user_id, room_id, role, publish_state, mic_session_id, room_version, status, last_seen_at_ms, updated_at_ms
FROM room_user_presence
WHERE app_code = ? AND user_id = ? AND status = ?
LIMIT 1`,
appcode.FromContext(ctx),
userID,
roomPresenceStatusActiveSQL,
)
var presence roomservice.RoomPresence
if err := row.Scan(
&presence.AppCode,
&presence.UserID,
&presence.RoomID,
&presence.Role,
&presence.PublishState,
&presence.MicSessionID,
&presence.RoomVersion,
&presence.Status,
&presence.LastSeenAtMS,
&presence.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomPresence{}, false, nil
}
return roomservice.RoomPresence{}, false, err
}
return presence, true, nil
}
const (
roomPresenceStatusActiveSQL = "active"
roomPresenceStatusLeftSQL = "left"
)
func escapeRoomListLike(value string) string {
var builder strings.Builder
for _, r := range value {

View File

@ -187,6 +187,20 @@ func (s *Server) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*
return mapServiceResult(s.svc.ListRooms(ctx, req))
}
// GetCurrentRoom 代理到用户当前房间恢复探测读模型。
func (s *Server) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
// 恢复探测不修改 Room Cell只在读模型命中后用快照做二次校验。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.GetCurrentRoom(ctx, req))
}
// GetRoomSnapshot 代理到房间页完整快照只读查询。
func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
// 快照查询不刷新 presence不替代 heartbeat也不能让未进房用户读取完整房间态。
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.GetRoomSnapshot(ctx, req))
}
func contextWithMetaApp(ctx context.Context, meta *roomv1.RequestMeta) context.Context {
if meta == nil {
return appcode.WithContext(ctx, "")

View File

@ -170,6 +170,26 @@ CREATE TABLE IF NOT EXISTS user_region_rebuild_tasks (
KEY idx_user_region_rebuild_tasks_lock (app_code, status, locked_until_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_push_tokens (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL,
device_id VARCHAR(128) NOT NULL,
push_token VARCHAR(512) NOT NULL,
provider VARCHAR(32) NOT NULL,
platform VARCHAR(16) NOT NULL,
app_version VARCHAR(64) NOT NULL DEFAULT '',
language VARCHAR(32) NOT NULL DEFAULT '',
timezone VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL,
bound_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
last_request_id VARCHAR(96) NOT NULL DEFAULT '',
PRIMARY KEY (app_code, user_id, device_id),
UNIQUE KEY uk_user_push_tokens_token (app_code, provider, push_token),
KEY idx_user_push_tokens_user_status (app_code, user_id, status),
KEY idx_user_push_tokens_updated (app_code, status, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS host_profiles (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
user_id BIGINT NOT NULL PRIMARY KEY,

View File

@ -81,6 +81,7 @@ func New(cfg config.Config) (*App, error) {
userRepo := mysqlRepo.UserRepository()
identityRepo := mysqlRepo.IdentityRepository()
regionRepo := mysqlRepo.RegionRepository()
deviceRepo := mysqlRepo.DeviceRepository()
hostRepo := mysqlRepo.HostRepository()
// auth service 负责登录、session 和 token用户主数据和短号事务仍通过各自领域存储完成。
@ -107,6 +108,7 @@ func New(cfg config.Config) (*App, error) {
userservice.WithCountryAdminRepository(regionRepo),
userservice.WithRegionAdminRepository(regionRepo),
userservice.WithRegionRebuildRepository(regionRepo),
userservice.WithDeviceRepository(deviceRepo),
userservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
userservice.WithDisplayUserIDPolicy(cfg.DisplayUserID.AllocateMaxAttempts, time.Duration(cfg.DisplayUserID.ChangeCooldownSec)*time.Second),
)
@ -117,6 +119,7 @@ func New(cfg config.Config) (*App, error) {
// 多个 protobuf service 共用一个 Server 适配器,领域逻辑仍拆在 auth/user/host service。
userv1.RegisterAuthServiceServer(server, userServer)
userv1.RegisterUserServiceServer(server, userServer)
userv1.RegisterUserDeviceServiceServer(server, userServer)
userv1.RegisterAppRegistryServiceServer(server, userServer)
userv1.RegisterUserIdentityServiceServer(server, userServer)
userv1.RegisterCountryAdminServiceServer(server, userServer)

View File

@ -0,0 +1,19 @@
package user
const (
// UserIDTargetAllActive supports low-frequency full-App jobs without exposing profile fields.
UserIDTargetAllActiveUsers = "all_active_users"
// UserIDTargetRegion selects active users by their materialized region_id snapshot.
UserIDTargetRegion = "region"
// UserIDTargetCountry selects active users by their current country code snapshot.
UserIDTargetCountry = "country"
)
// UserIDPageFilter is the minimal user-service target query used by async fanout jobs.
type UserIDPageFilter struct {
TargetScope string
CursorUserID int64
PageSize int
RegionID int64
Country string
}

View File

@ -267,6 +267,52 @@ type CountryChangeLog struct {
ChangedAtMs int64
}
// PushTokenStatus 表达用户设备推送 token 是否仍可用于离线触达。
type PushTokenStatus string
const (
// PushTokenStatusActive 表示该 token 当前可用于系统/活动消息离线推送。
PushTokenStatusActive PushTokenStatus = "active"
// PushTokenStatusInactive 表示该 token 已被用户登出、关闭通知或重新绑定替换。
PushTokenStatusInactive PushTokenStatus = "inactive"
)
// PushTokenCommand 描述一次设备 push token 绑定。
type PushTokenCommand struct {
// AppCode 是 token 所属 App避免不同包之间串推送。
AppCode string
// UserID 是当前登录用户,必须由 gateway 鉴权后传入。
UserID int64
// DeviceID 是 App 安装级稳定 ID。
DeviceID string
// PushToken 是系统推送服务返回的 token。
PushToken string
// Provider 是推送提供方,例如 fcm 或 apns。
Provider string
// Platform 是客户端平台,当前只接受 android/ios。
Platform string
// AppVersion 是上报 token 时的 App 版本快照。
AppVersion string
// Language 是用户当前客户端语言偏好。
Language string
// Timezone 是用户当前 IANA 时区。
Timezone string
// RequestID 是入口请求 ID便于排查重复绑定。
RequestID string
// UpdatedAtMs 是绑定事务时间。
UpdatedAtMs int64
}
// DeletePushTokenCommand 描述一次设备 push token 失效。
type DeletePushTokenCommand struct {
AppCode string
UserID int64
DeviceID string
PushToken string
RequestID string
UpdatedAtMs int64
}
// PrettyDisplayUserIDLease 是临时靓号覆盖关系的事实记录。
type PrettyDisplayUserIDLease struct {
// AppCode 是租约所属 App。

View File

@ -53,6 +53,7 @@ func newUserService(repository *mysqltest.Repository, options ...userservice.Opt
userservice.WithCountryAdminRepository(repository),
userservice.WithRegionAdminRepository(repository),
userservice.WithRegionRebuildRepository(repository),
userservice.WithDeviceRepository(repository),
}
return userservice.New(repository, append(base, options...)...)
}

View File

@ -0,0 +1,147 @@
package user
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
maxPushDeviceIDLen = 128
maxPushTokenLen = 512
maxPushProviderLen = 32
maxPushPlatformLen = 16
maxPushAppVersionLen = 64
maxPushLanguageLen = 32
maxPushTimezoneLen = 64
)
// BindPushToken 绑定当前登录用户的系统推送 token。
// 该能力不影响 App 进入主界面;失败只影响离线消息触达。
func (s *Service) BindPushToken(ctx context.Context, userID int64, deviceID string, pushToken string, provider string, platform string, appVersion string, language string, timezone string, requestID string) (int64, error) {
if s.deviceRepository == nil {
return 0, xerr.New(xerr.Unavailable, "device repository is not configured")
}
if err := s.requireActiveCompletedUser(ctx, userID); err != nil {
return 0, err
}
command, err := normalizePushTokenCommand(ctx, userID, deviceID, pushToken, provider, platform, appVersion, language, timezone, requestID, s.now().UnixMilli())
if err != nil {
return 0, err
}
if err := s.deviceRepository.BindPushToken(ctx, command); err != nil {
return 0, err
}
return command.UpdatedAtMs, nil
}
// DeletePushToken 失效当前登录用户某个设备的推送 token。
func (s *Service) DeletePushToken(ctx context.Context, userID int64, deviceID string, pushToken string, requestID string) (bool, int64, error) {
if s.deviceRepository == nil {
return false, 0, xerr.New(xerr.Unavailable, "device repository is not configured")
}
if userID <= 0 {
return false, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
deviceID = strings.TrimSpace(deviceID)
pushToken = strings.TrimSpace(pushToken)
if deviceID == "" || len(deviceID) > maxPushDeviceIDLen {
return false, 0, xerr.New(xerr.InvalidArgument, "device_id is invalid")
}
if len(pushToken) > maxPushTokenLen {
return false, 0, xerr.New(xerr.InvalidArgument, "push_token is invalid")
}
nowMs := s.now().UnixMilli()
deleted, err := s.deviceRepository.DeletePushToken(ctx, userdomain.DeletePushTokenCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
DeviceID: deviceID,
PushToken: pushToken,
RequestID: strings.TrimSpace(requestID),
UpdatedAtMs: nowMs,
})
if err != nil {
return false, 0, err
}
return deleted, nowMs, nil
}
func (s *Service) requireActiveCompletedUser(ctx context.Context, userID int64) error {
if userID <= 0 {
return xerr.New(xerr.InvalidArgument, "user_id is required")
}
user, err := s.GetUser(ctx, userID)
if err != nil {
return err
}
if !user.ProfileCompleted {
return xerr.New(xerr.ProfileRequired, "profile required")
}
if user.Status != userdomain.StatusActive {
return xerr.New(xerr.UserDisabled, "user disabled")
}
return nil
}
func normalizePushTokenCommand(ctx context.Context, userID int64, deviceID string, pushToken string, provider string, platform string, appVersion string, language string, timezone string, requestID string, nowMs int64) (userdomain.PushTokenCommand, error) {
deviceID = strings.TrimSpace(deviceID)
pushToken = strings.TrimSpace(pushToken)
platform = strings.ToLower(strings.TrimSpace(platform))
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
provider = defaultPushProvider(platform)
}
if deviceID == "" || len(deviceID) > maxPushDeviceIDLen {
return userdomain.PushTokenCommand{}, xerr.New(xerr.InvalidArgument, "device_id is invalid")
}
if pushToken == "" || len(pushToken) > maxPushTokenLen {
return userdomain.PushTokenCommand{}, xerr.New(xerr.InvalidArgument, "push_token is invalid")
}
if provider == "" || len(provider) > maxPushProviderLen {
return userdomain.PushTokenCommand{}, xerr.New(xerr.InvalidArgument, "provider is invalid")
}
if platform != "android" && platform != "ios" {
return userdomain.PushTokenCommand{}, xerr.New(xerr.InvalidArgument, "platform is invalid")
}
appVersion = strings.TrimSpace(appVersion)
language = strings.TrimSpace(language)
timezone = strings.TrimSpace(timezone)
if len(appVersion) > maxPushAppVersionLen || len(language) > maxPushLanguageLen || len(timezone) > maxPushTimezoneLen {
return userdomain.PushTokenCommand{}, xerr.New(xerr.InvalidArgument, "push token metadata is invalid")
}
return userdomain.PushTokenCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
DeviceID: deviceID,
PushToken: pushToken,
Provider: provider,
Platform: platform,
AppVersion: appVersion,
Language: language,
Timezone: timezone,
RequestID: strings.TrimSpace(requestID),
UpdatedAtMs: nowMs,
}, nil
}
func defaultPushProvider(platform string) string {
switch platform {
case "android":
return "fcm"
case "ios":
return "apns"
default:
return ""
}
}

View File

@ -16,6 +16,8 @@ type UserRepository interface {
GetUser(ctx context.Context, userID int64) (userdomain.User, error)
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
// CreateUserWithIdentity 原子创建用户和默认短号。
CreateUserWithIdentity(ctx context.Context, createdUser userdomain.User, identity userdomain.Identity) error
// UpdateUserProfile 原子修改用户基础资料。
@ -79,6 +81,15 @@ type IdentityRepository interface {
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
}
// DeviceRepository 隔离设备 push token 的持久化。
// 推送 token 只服务离线触达,不进入认证、房间或钱包主链路。
type DeviceRepository interface {
// BindPushToken 原子绑定或替换当前用户设备 token。
BindPushToken(ctx context.Context, command userdomain.PushTokenCommand) error
// DeletePushToken 失效当前用户设备 token。
DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error)
}
// IDGenerator 生成系统内部不可变 user_id。
type IDGenerator interface {
// NewInt64 返回全局唯一 user_id。
@ -111,6 +122,8 @@ type Service struct {
regionAdminRepository RegionAdminRepository
// regionRebuildRepository 持有历史用户区域快照重算能力。
regionRebuildRepository RegionRebuildRepository
// deviceRepository 持有设备 push token 绑定能力。
deviceRepository DeviceRepository
// idGenerator 生成系统内部不可变 user_id。
idGenerator IDGenerator
// displayUserIDAllocator 生成默认短号候选,唯一性由 repository 保证。
@ -194,6 +207,15 @@ func WithRegionRebuildRepository(repository RegionRebuildRepository) Option {
}
}
// WithDeviceRepository 注入设备 push token repository。
func WithDeviceRepository(repository DeviceRepository) Option {
return func(s *Service) {
if repository != nil {
s.deviceRepository = repository
}
}
}
// WithCountryChangeCooldown 配置用户国家修改冷却窗口。
func WithCountryChangeCooldown(cooldown time.Duration) Option {
return func(s *Service) {

View File

@ -88,6 +88,7 @@ func newUserService(repository *mysqltest.Repository, options ...userservice.Opt
userservice.WithCountryAdminRepository(repository),
userservice.WithRegionAdminRepository(repository),
userservice.WithRegionRebuildRepository(repository),
userservice.WithDeviceRepository(repository),
}
return userservice.New(repository, append(base, options...)...)
}
@ -136,6 +137,35 @@ func TestGetUserUsesRepository(t *testing.T) {
}
}
func TestListUserIDsUsesCursorAndTargetFilters(t *testing.T) {
repository := mysqltest.NewRepository(t)
for _, user := range []userdomain.User{
{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive, Country: "US", RegionID: 7},
{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive, Country: "SG", RegionID: 8},
{UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive, Country: "US", RegionID: 7},
{UserID: 10004, CurrentDisplayUserID: "100004", Status: userdomain.StatusDisabled, Country: "US", RegionID: 7},
} {
repository.PutUser(completedUser(user))
}
svc := newUserService(repository)
userIDs, next, done, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetAllActiveUsers, PageSize: 2})
if err != nil || done || next != 10002 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10002 {
t.Fatalf("all active first page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetRegion, RegionID: 7, CursorUserID: next, PageSize: 10})
if err != nil || !done || next != 10003 || len(userIDs) != 1 || userIDs[0] != 10003 {
t.Fatalf("region page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: " us ", PageSize: 10})
if err != nil || !done || next != 10003 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10003 {
t.Fatalf("country page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err)
}
if _, _, _, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: "1"}); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("invalid country should fail, got %v", err)
}
}
func TestUpdateUserProfile(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.PutUser(completedUser(userdomain.User{
@ -213,6 +243,67 @@ func TestUpdateUserProfile(t *testing.T) {
}
}
func TestBindAndDeletePushToken(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
current := time.UnixMilli(1700000000000)
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
repository.PutUser(completedUser(userdomain.User{
UserID: 10001,
DefaultDisplayUserID: "100001",
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
}))
updatedAt, err := svc.BindPushToken(ctx, 10001, " dev-1 ", " push-token-1 ", "", " android ", "1.2.3", "en-US", "America/Los_Angeles", "req-push-bind")
if err != nil {
t.Fatalf("BindPushToken failed: %v", err)
}
if updatedAt != 1700000000000 {
t.Fatalf("updated_at mismatch: got %d", updatedAt)
}
deleted, deletedAt, err := svc.DeletePushToken(ctx, 10001, "dev-1", "push-token-1", "req-push-delete")
if err != nil {
t.Fatalf("DeletePushToken failed: %v", err)
}
if !deleted || deletedAt != 1700000000000 {
t.Fatalf("delete response mismatch: deleted=%v deletedAt=%d", deleted, deletedAt)
}
}
func TestBindPushTokenValidation(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newUserService(repository)
repository.PutUser(completedUser(userdomain.User{
UserID: 10001,
DefaultDisplayUserID: "100001",
CurrentDisplayUserID: "100001",
Status: userdomain.StatusActive,
}))
tests := []struct {
name string
deviceID string
token string
platform string
wantCode xerr.Code
}{
{name: "device", deviceID: "", token: "push-1", platform: "android", wantCode: xerr.InvalidArgument},
{name: "token", deviceID: "dev-1", token: "", platform: "android", wantCode: xerr.InvalidArgument},
{name: "platform", deviceID: "dev-1", token: "push-1", platform: "web", wantCode: xerr.InvalidArgument},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := svc.BindPushToken(ctx, 10001, test.deviceID, test.token, "", test.platform, "", "", "", "req-"+test.name)
if !xerr.IsCode(err, test.wantCode) {
t.Fatalf("error code mismatch: got %v want %s", err, test.wantCode)
}
})
}
}
func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing.T) {
// CompleteOnboarding 是注册页原子提交点,首次国家写入不能污染后续国家修改冷却日志。
ctx := context.Background()

View File

@ -2,12 +2,18 @@ package user
import (
"context"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
defaultUserIDPageSize = 500
maxUserIDPageSize = 5000
)
// GetUser 查询单个用户主状态。
func (s *Service) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
if userID <= 0 {
@ -65,6 +71,52 @@ func (s *Service) BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64
return users, nil
}
// ListUserIDs returns active user IDs for low-frequency backend fanout tasks.
func (s *Service) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, int64, bool, error) {
if s.userRepository == nil {
return nil, 0, false, xerr.New(xerr.Unavailable, "user repository is not configured")
}
filter.TargetScope = strings.TrimSpace(filter.TargetScope)
filter.Country = userdomain.NormalizeCountryCode(filter.Country)
filter.PageSize = normalizeUserIDPageSize(filter.PageSize)
if filter.CursorUserID < 0 {
return nil, 0, false, xerr.New(xerr.InvalidArgument, "cursor_user_id is invalid")
}
switch filter.TargetScope {
case userdomain.UserIDTargetAllActiveUsers:
case userdomain.UserIDTargetRegion:
if filter.RegionID <= 0 {
return nil, 0, false, xerr.New(xerr.InvalidArgument, "region_id is required")
}
case userdomain.UserIDTargetCountry:
if !userdomain.ValidCountryCode(filter.Country) {
return nil, 0, false, xerr.New(xerr.InvalidArgument, "country is invalid")
}
default:
return nil, 0, false, xerr.New(xerr.InvalidArgument, "target_scope is invalid")
}
userIDs, err := s.userRepository.ListUserIDs(ctx, filter)
if err != nil {
return nil, 0, false, err
}
nextCursor := filter.CursorUserID
if len(userIDs) > 0 {
nextCursor = userIDs[len(userIDs)-1]
}
return userIDs, nextCursor, len(userIDs) < filter.PageSize, nil
}
func normalizeUserIDPageSize(value int) int {
if value <= 0 {
return defaultUserIDPageSize
}
if value > maxUserIDPageSize {
return maxUserIDPageSize
}
return value
}
// CreateUser 创建用户主记录,并同时分配 active display_user_id。
// 该方法供注册和三方首次登录后续接入,避免出现没有短号的用户。
func (s *Service) CreateUser(ctx context.Context) (userdomain.User, userdomain.Identity, error) {

View File

@ -0,0 +1,119 @@
// Package device implements user-service push token persistence.
package device
import (
"context"
"database/sql"
"strings"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// Repository stores App push tokens in MySQL.
type Repository struct {
db *sql.DB
}
// New creates a device repository over the shared user-service connection pool.
func New(db *sql.DB) *Repository {
return &Repository{db: db}
}
// BindPushToken binds a push token to the current user/device and removes stale ownership of the same token.
func (r *Repository) BindPushToken(ctx context.Context, command userdomain.PushTokenCommand) error {
appCode := normalizedAppCode(ctx, command.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
// 同一个系统 push token 只能投递一次;重新登录其他账号时删除旧归属,避免离线消息双发。
if _, err := tx.ExecContext(ctx,
`DELETE FROM user_push_tokens
WHERE app_code = ? AND provider = ? AND push_token = ?
AND NOT (user_id = ? AND device_id = ?)`,
appCode,
command.Provider,
command.PushToken,
command.UserID,
command.DeviceID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO user_push_tokens (
app_code, user_id, device_id, push_token, provider, platform, app_version, language, timezone,
status, bound_at_ms, updated_at_ms, last_request_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
push_token = VALUES(push_token),
provider = VALUES(provider),
platform = VALUES(platform),
app_version = VALUES(app_version),
language = VALUES(language),
timezone = VALUES(timezone),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms),
last_request_id = VALUES(last_request_id)`,
appCode,
command.UserID,
command.DeviceID,
command.PushToken,
command.Provider,
command.Platform,
command.AppVersion,
command.Language,
command.Timezone,
userdomain.PushTokenStatusActive,
command.UpdatedAtMs,
command.UpdatedAtMs,
command.RequestID,
); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// DeletePushToken marks one user's device token inactive.
func (r *Repository) DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error) {
appCode := normalizedAppCode(ctx, command.AppCode)
query := `UPDATE user_push_tokens
SET status = ?, updated_at_ms = ?, last_request_id = ?
WHERE app_code = ? AND user_id = ? AND device_id = ? AND status = ?`
args := []any{
userdomain.PushTokenStatusInactive,
command.UpdatedAtMs,
command.RequestID,
appCode,
command.UserID,
command.DeviceID,
userdomain.PushTokenStatusActive,
}
if strings.TrimSpace(command.PushToken) != "" {
query += ` AND push_token = ?`
args = append(args, command.PushToken)
}
result, err := r.db.ExecContext(ctx, query, args...)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
return affected > 0, nil
}
func normalizedAppCode(ctx context.Context, value string) string {
value = strings.TrimSpace(value)
if value == "" {
return appcode.FromContext(ctx)
}
return appcode.Normalize(value)
}

View File

@ -3,6 +3,7 @@ package mysql
import (
appstorage "hyapp/services/user-service/internal/storage/mysql/app"
authstorage "hyapp/services/user-service/internal/storage/mysql/auth"
devicestorage "hyapp/services/user-service/internal/storage/mysql/device"
hoststorage "hyapp/services/user-service/internal/storage/mysql/host"
identitystorage "hyapp/services/user-service/internal/storage/mysql/identity"
regionstorage "hyapp/services/user-service/internal/storage/mysql/region"
@ -49,6 +50,14 @@ func (r *Repository) RegionRepository() *regionstorage.Repository {
return regionstorage.New(r.db)
}
// 基于 user-service 共享 MySQL 连接池返回设备推送 token 存储对象。
func (r *Repository) DeviceRepository() *devicestorage.Repository {
if r == nil {
return nil
}
return devicestorage.New(r.db)
}
// 基于 user-service 共享 MySQL 连接池返回 Host 领域存储对象。
func (r *Repository) HostRepository() *hoststorage.Repository {
if r == nil {

View File

@ -61,6 +61,42 @@ func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[in
return result, rows.Err()
}
// ListUserIDs returns active user IDs in ascending order for cursor-based backend fanout.
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
conditions := []string{"app_code = ?", "status = ?", "user_id > ?"}
args := []any{appcode.FromContext(ctx), string(userdomain.StatusActive), filter.CursorUserID}
switch filter.TargetScope {
case userdomain.UserIDTargetRegion:
conditions = append(conditions, "region_id = ?")
args = append(args, filter.RegionID)
case userdomain.UserIDTargetCountry:
conditions = append(conditions, "country = ?")
args = append(args, filter.Country)
}
args = append(args, filter.PageSize)
rows, err := r.db.QueryContext(ctx, `
SELECT user_id
FROM users
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY user_id ASC
LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
userIDs := make([]int64, 0, filter.PageSize)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return userIDs, rows.Err()
}
// CreateUserWithIdentity 是用户创建的事务入口。
// 它同时写 users 和默认短号 active 记录,避免出现没有默认门牌号的用户。

View File

@ -61,6 +61,11 @@ func (r *Repository) BatchGetUsers(ctx context.Context, userIDs []int64) (map[in
return r.Repository.UserRepository().BatchGetUsers(ctx, userIDs)
}
// ListUserIDs 让测试 wrapper 继续满足后台 fanout 目标用户查询接口。
func (r *Repository) ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error) {
return r.Repository.UserRepository().ListUserIDs(ctx, filter)
}
// CreateUserWithIdentity 让测试 wrapper 继续满足业务层用户创建接口。
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user userdomain.User, identity userdomain.Identity) error {
return r.Repository.UserRepository().CreateUserWithIdentity(ctx, user, identity)
@ -81,6 +86,16 @@ func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.C
return r.Repository.UserRepository().ChangeUserCountry(ctx, command)
}
// BindPushToken 让测试 wrapper 继续满足设备 push token repository。
func (r *Repository) BindPushToken(ctx context.Context, command userdomain.PushTokenCommand) error {
return r.Repository.DeviceRepository().BindPushToken(ctx, command)
}
// DeletePushToken 让测试 wrapper 继续满足设备 push token repository。
func (r *Repository) DeletePushToken(ctx context.Context, command userdomain.DeletePushTokenCommand) (bool, error) {
return r.Repository.DeviceRepository().DeletePushToken(ctx, command)
}
// AllocateDisplayUserID 让测试 wrapper 继续满足认证注册接口。
func (r *Repository) AllocateDisplayUserID(ctx context.Context, appCode string) (string, error) {
return r.Repository.AuthRepository().AllocateDisplayUserID(ctx, appCode)

View File

@ -20,6 +20,8 @@ type Server struct {
userv1.UnimplementedAuthServiceServer
// UnimplementedUserServiceServer 提供未实现 RPC 的默认返回。
userv1.UnimplementedUserServiceServer
// UnimplementedUserDeviceServiceServer 提供设备推送 token RPC 的默认返回。
userv1.UnimplementedUserDeviceServiceServer
// UnimplementedAppRegistryServiceServer 提供 App 注册表 RPC 的默认返回。
userv1.UnimplementedAppRegistryServiceServer
// UnimplementedUserIdentityServiceServer 提供未实现 RPC 的默认返回。
@ -175,6 +177,22 @@ func (s *Server) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersReq
return &userv1.BatchGetUsersResponse{Users: result}, nil
}
// ListUserIDs 为后台 fanout 任务返回稳定递增的用户 ID 游标页。
func (s *Server) ListUserIDs(ctx context.Context, req *userv1.ListUserIDsRequest) (*userv1.ListUserIDsResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
userIDs, nextCursor, done, err := s.userSvc.ListUserIDs(ctx, userdomain.UserIDPageFilter{
TargetScope: req.GetTargetScope(),
CursorUserID: req.GetCursorUserId(),
PageSize: int(req.GetPageSize()),
RegionID: req.GetRegionId(),
Country: req.GetCountry(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.ListUserIDsResponse{UserIds: userIDs, NextCursorUserId: nextCursor, Done: done}, nil
}
// UpdateUserProfile 修改当前用户基础资料。
func (s *Server) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
@ -230,6 +248,26 @@ func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnb
}, nil
}
// BindPushToken 绑定当前用户设备推送 token。
func (s *Server) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
updatedAtMS, err := s.userSvc.BindPushToken(ctx, req.GetUserId(), req.GetDeviceId(), req.GetPushToken(), req.GetProvider(), req.GetPlatform(), req.GetAppVersion(), req.GetLanguage(), req.GetTimezone(), req.GetMeta().GetRequestId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.BindPushTokenResponse{Bound: true, UpdatedAtMs: updatedAtMS}, nil
}
// DeletePushToken 失效当前用户设备推送 token。
func (s *Server) DeletePushToken(ctx context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
deleted, updatedAtMS, err := s.userSvc.DeletePushToken(ctx, req.GetUserId(), req.GetDeviceId(), req.GetPushToken(), req.GetMeta().GetRequestId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.DeletePushTokenResponse{Deleted: deleted, UpdatedAtMs: updatedAtMS}, nil
}
// ListCountries 返回国家主数据列表。
func (s *Server) ListCountries(ctx context.Context, req *userv1.ListCountriesRequest) (*userv1.ListCountriesResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -22,6 +22,7 @@ func newUserService(repository *mysqltest.Repository, options ...userservice.Opt
userservice.WithCountryAdminRepository(repository),
userservice.WithRegionAdminRepository(repository),
userservice.WithRegionRebuildRepository(repository),
userservice.WithDeviceRepository(repository),
}
return userservice.New(repository, append(base, options...)...)
}