完善接口流程
This commit is contained in:
parent
975d21a63a
commit
15760a55c2
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
@ -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
@ -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);
|
||||
|
||||
@ -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
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
382
docs/app-entry-state-machine.md
Normal file
382
docs/app-entry-state-machine.md
Normal 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 过期时优先 refresh,refresh 失败才回登录页。
|
||||
- 主界面首屏只调用轻量一级接口:房间、消息、我的。
|
||||
- 腾讯云 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` | 默认 hot;new 用同一接口 |
|
||||
| 消息 | `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 绑定/解绑 RPC,gateway 已暴露 `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 只返回配置摘要和版本,不返回大列表。
|
||||
593
docs/app-message-tab-architecture.md
Normal file
593
docs/app-message-tab-architecture.md
Normal 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 worker;region/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。
|
||||
296
docs/my-page-overview-api.md
Normal file
296
docs/my-page-overview-api.md
Normal 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 为准。
|
||||
@ -17,6 +17,8 @@ produces:
|
||||
tags:
|
||||
- name: activity
|
||||
description: ActivityService,活动服务同步查询接口。
|
||||
- name: messages
|
||||
description: MessageInboxService,App 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:
|
||||
|
||||
@ -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: 资料完成后由客户端上报系统推送 token;gateway 只使用 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_id,room-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 作为 viewer,room-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 默认 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
|
||||
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"
|
||||
|
||||
@ -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,不隐式 JoinRoom,viewer 必须仍在该房间。
|
||||
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:
|
||||
|
||||
@ -18,6 +18,8 @@ tags:
|
||||
description: AuthService,登录注册和 token 生命周期。
|
||||
- name: users
|
||||
description: UserService,用户主状态读取。
|
||||
- name: user-devices
|
||||
description: UserDeviceService,App 设备推送 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:
|
||||
|
||||
493
docs/voice-room-client-api-flow.md
Normal file
493
docs/voice-room-client-api-flow.md
Normal 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 会自动 MicDown,reason 为 `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` 表示确认当前 actor;RTC 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 作为 viewer,room-service 要求 viewer 仍在该房间;未进房、被踢或被 ban 的用户不能读取完整在线用户和麦位快照。
|
||||
@ -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 层统一返回的业务错误。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
165
services/activity-service/internal/domain/message/message.go
Normal file
165
services/activity-service/internal/domain/message/message.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
549
services/activity-service/internal/service/message/service.go
Normal file
549
services/activity-service/internal/service/message/service.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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_configs,gateway 只读下发给 App。
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
|
||||
@ -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_configs,gateway 只读下发给 App。
|
||||
mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
|
||||
@ -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_configs,gateway 只读下发给 App。
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
46
services/gateway-service/internal/client/message_client.go
Normal file
46
services/gateway-service/internal/client/message_client.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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},
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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{})
|
||||
|
||||
@ -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_id;presence 和麦位状态由 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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)))
|
||||
|
||||
152
services/room-service/internal/room/service/current_room.go
Normal file
152
services/room-service/internal/room/service/current_room.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 timeout;worker 必须能在无其他 owner 时续租并执行超时下麦。
|
||||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||||
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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, "")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
19
services/user-service/internal/domain/user/target_query.go
Normal file
19
services/user-service/internal/domain/user/target_query.go
Normal 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
|
||||
}
|
||||
@ -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。
|
||||
|
||||
@ -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...)...)
|
||||
}
|
||||
|
||||
147
services/user-service/internal/service/user/device.go
Normal file
147
services/user-service/internal/service/user/device.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 记录,避免出现没有默认门牌号的用户。
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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...)...)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user