消息相关
This commit is contained in:
parent
35060cc1fc
commit
bf2735d068
File diff suppressed because it is too large
Load Diff
@ -178,6 +178,67 @@ message CreateFanoutJobResponse {
|
||||
bool created = 3;
|
||||
}
|
||||
|
||||
// ActionConfirm 是 App C2C IM 操作按钮背后的后端确认事实。
|
||||
message ActionConfirm {
|
||||
string confirm_id = 1;
|
||||
string business_type = 2;
|
||||
string business_subtype = 3;
|
||||
string business_id = 4;
|
||||
int64 sender_user_id = 5;
|
||||
int64 receiver_user_id = 6;
|
||||
string status = 7;
|
||||
string action = 8;
|
||||
int64 processed_at_ms = 9;
|
||||
int64 created_at_ms = 10;
|
||||
int64 updated_at_ms = 11;
|
||||
}
|
||||
|
||||
message AcceptActionConfirmRequest {
|
||||
RequestMeta meta = 1;
|
||||
string confirm_id = 2;
|
||||
int64 actor_user_id = 3;
|
||||
string command_id = 4;
|
||||
}
|
||||
|
||||
message AcceptActionConfirmResponse {
|
||||
ActionConfirm confirm = 1;
|
||||
}
|
||||
|
||||
message RejectActionConfirmRequest {
|
||||
RequestMeta meta = 1;
|
||||
string confirm_id = 2;
|
||||
int64 actor_user_id = 3;
|
||||
string command_id = 4;
|
||||
string reason = 5;
|
||||
}
|
||||
|
||||
message RejectActionConfirmResponse {
|
||||
ActionConfirm confirm = 1;
|
||||
}
|
||||
|
||||
message BatchGetActionConfirmStatusRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
repeated string confirm_ids = 3;
|
||||
}
|
||||
|
||||
message BatchGetActionConfirmStatusResponse {
|
||||
repeated ActionConfirm items = 1;
|
||||
}
|
||||
|
||||
message ListConversationActionConfirmsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
int64 peer_user_id = 3;
|
||||
int32 page_size = 4;
|
||||
string page_token = 5;
|
||||
}
|
||||
|
||||
message ListConversationActionConfirmsResponse {
|
||||
repeated ActionConfirm items = 1;
|
||||
string next_page_token = 2;
|
||||
}
|
||||
|
||||
// CronBatchRequest 是 cron-service 触发 activity-service 后台批处理的统一请求。
|
||||
message CronBatchRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -2465,6 +2526,14 @@ service MessageInboxService {
|
||||
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
|
||||
}
|
||||
|
||||
// MessageActionConfirmService 拥有 IM 操作按钮的确认状态,业务执行仍回到 owner service。
|
||||
service MessageActionConfirmService {
|
||||
rpc AcceptActionConfirm(AcceptActionConfirmRequest) returns (AcceptActionConfirmResponse);
|
||||
rpc RejectActionConfirm(RejectActionConfirmRequest) returns (RejectActionConfirmResponse);
|
||||
rpc BatchGetActionConfirmStatus(BatchGetActionConfirmStatusRequest) returns (BatchGetActionConfirmStatusResponse);
|
||||
rpc ListConversationActionConfirms(ListConversationActionConfirmsRequest) returns (ListConversationActionConfirmsResponse);
|
||||
}
|
||||
|
||||
// ActivityCronService 只给 cron-service 调用,活动和消息状态仍由 activity-service owner 修改。
|
||||
service ActivityCronService {
|
||||
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||
|
||||
@ -496,6 +496,227 @@ var MessageInboxService_ServiceDesc = grpc.ServiceDesc{
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
MessageActionConfirmService_AcceptActionConfirm_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/AcceptActionConfirm"
|
||||
MessageActionConfirmService_RejectActionConfirm_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/RejectActionConfirm"
|
||||
MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/BatchGetActionConfirmStatus"
|
||||
MessageActionConfirmService_ListConversationActionConfirms_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/ListConversationActionConfirms"
|
||||
)
|
||||
|
||||
// MessageActionConfirmServiceClient is the client API for MessageActionConfirmService 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.
|
||||
//
|
||||
// MessageActionConfirmService 拥有 IM 操作按钮的确认状态,业务执行仍回到 owner service。
|
||||
type MessageActionConfirmServiceClient interface {
|
||||
AcceptActionConfirm(ctx context.Context, in *AcceptActionConfirmRequest, opts ...grpc.CallOption) (*AcceptActionConfirmResponse, error)
|
||||
RejectActionConfirm(ctx context.Context, in *RejectActionConfirmRequest, opts ...grpc.CallOption) (*RejectActionConfirmResponse, error)
|
||||
BatchGetActionConfirmStatus(ctx context.Context, in *BatchGetActionConfirmStatusRequest, opts ...grpc.CallOption) (*BatchGetActionConfirmStatusResponse, error)
|
||||
ListConversationActionConfirms(ctx context.Context, in *ListConversationActionConfirmsRequest, opts ...grpc.CallOption) (*ListConversationActionConfirmsResponse, error)
|
||||
}
|
||||
|
||||
type messageActionConfirmServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewMessageActionConfirmServiceClient(cc grpc.ClientConnInterface) MessageActionConfirmServiceClient {
|
||||
return &messageActionConfirmServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *messageActionConfirmServiceClient) AcceptActionConfirm(ctx context.Context, in *AcceptActionConfirmRequest, opts ...grpc.CallOption) (*AcceptActionConfirmResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AcceptActionConfirmResponse)
|
||||
err := c.cc.Invoke(ctx, MessageActionConfirmService_AcceptActionConfirm_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageActionConfirmServiceClient) RejectActionConfirm(ctx context.Context, in *RejectActionConfirmRequest, opts ...grpc.CallOption) (*RejectActionConfirmResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RejectActionConfirmResponse)
|
||||
err := c.cc.Invoke(ctx, MessageActionConfirmService_RejectActionConfirm_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageActionConfirmServiceClient) BatchGetActionConfirmStatus(ctx context.Context, in *BatchGetActionConfirmStatusRequest, opts ...grpc.CallOption) (*BatchGetActionConfirmStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetActionConfirmStatusResponse)
|
||||
err := c.cc.Invoke(ctx, MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *messageActionConfirmServiceClient) ListConversationActionConfirms(ctx context.Context, in *ListConversationActionConfirmsRequest, opts ...grpc.CallOption) (*ListConversationActionConfirmsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListConversationActionConfirmsResponse)
|
||||
err := c.cc.Invoke(ctx, MessageActionConfirmService_ListConversationActionConfirms_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MessageActionConfirmServiceServer is the server API for MessageActionConfirmService service.
|
||||
// All implementations must embed UnimplementedMessageActionConfirmServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// MessageActionConfirmService 拥有 IM 操作按钮的确认状态,业务执行仍回到 owner service。
|
||||
type MessageActionConfirmServiceServer interface {
|
||||
AcceptActionConfirm(context.Context, *AcceptActionConfirmRequest) (*AcceptActionConfirmResponse, error)
|
||||
RejectActionConfirm(context.Context, *RejectActionConfirmRequest) (*RejectActionConfirmResponse, error)
|
||||
BatchGetActionConfirmStatus(context.Context, *BatchGetActionConfirmStatusRequest) (*BatchGetActionConfirmStatusResponse, error)
|
||||
ListConversationActionConfirms(context.Context, *ListConversationActionConfirmsRequest) (*ListConversationActionConfirmsResponse, error)
|
||||
mustEmbedUnimplementedMessageActionConfirmServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedMessageActionConfirmServiceServer 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 UnimplementedMessageActionConfirmServiceServer struct{}
|
||||
|
||||
func (UnimplementedMessageActionConfirmServiceServer) AcceptActionConfirm(context.Context, *AcceptActionConfirmRequest) (*AcceptActionConfirmResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AcceptActionConfirm not implemented")
|
||||
}
|
||||
func (UnimplementedMessageActionConfirmServiceServer) RejectActionConfirm(context.Context, *RejectActionConfirmRequest) (*RejectActionConfirmResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RejectActionConfirm not implemented")
|
||||
}
|
||||
func (UnimplementedMessageActionConfirmServiceServer) BatchGetActionConfirmStatus(context.Context, *BatchGetActionConfirmStatusRequest) (*BatchGetActionConfirmStatusResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetActionConfirmStatus not implemented")
|
||||
}
|
||||
func (UnimplementedMessageActionConfirmServiceServer) ListConversationActionConfirms(context.Context, *ListConversationActionConfirmsRequest) (*ListConversationActionConfirmsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListConversationActionConfirms not implemented")
|
||||
}
|
||||
func (UnimplementedMessageActionConfirmServiceServer) mustEmbedUnimplementedMessageActionConfirmServiceServer() {
|
||||
}
|
||||
func (UnimplementedMessageActionConfirmServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeMessageActionConfirmServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to MessageActionConfirmServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeMessageActionConfirmServiceServer interface {
|
||||
mustEmbedUnimplementedMessageActionConfirmServiceServer()
|
||||
}
|
||||
|
||||
func RegisterMessageActionConfirmServiceServer(s grpc.ServiceRegistrar, srv MessageActionConfirmServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedMessageActionConfirmServiceServer 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(&MessageActionConfirmService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _MessageActionConfirmService_AcceptActionConfirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AcceptActionConfirmRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageActionConfirmServiceServer).AcceptActionConfirm(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageActionConfirmService_AcceptActionConfirm_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageActionConfirmServiceServer).AcceptActionConfirm(ctx, req.(*AcceptActionConfirmRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageActionConfirmService_RejectActionConfirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RejectActionConfirmRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageActionConfirmServiceServer).RejectActionConfirm(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageActionConfirmService_RejectActionConfirm_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageActionConfirmServiceServer).RejectActionConfirm(ctx, req.(*RejectActionConfirmRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageActionConfirmService_BatchGetActionConfirmStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetActionConfirmStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageActionConfirmServiceServer).BatchGetActionConfirmStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageActionConfirmServiceServer).BatchGetActionConfirmStatus(ctx, req.(*BatchGetActionConfirmStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MessageActionConfirmService_ListConversationActionConfirms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListConversationActionConfirmsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MessageActionConfirmServiceServer).ListConversationActionConfirms(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MessageActionConfirmService_ListConversationActionConfirms_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MessageActionConfirmServiceServer).ListConversationActionConfirms(ctx, req.(*ListConversationActionConfirmsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// MessageActionConfirmService_ServiceDesc is the grpc.ServiceDesc for MessageActionConfirmService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var MessageActionConfirmService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.activity.v1.MessageActionConfirmService",
|
||||
HandlerType: (*MessageActionConfirmServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "AcceptActionConfirm",
|
||||
Handler: _MessageActionConfirmService_AcceptActionConfirm_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RejectActionConfirm",
|
||||
Handler: _MessageActionConfirmService_RejectActionConfirm_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetActionConfirmStatus",
|
||||
Handler: _MessageActionConfirmService_BatchGetActionConfirmStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListConversationActionConfirms",
|
||||
Handler: _MessageActionConfirmService_ListConversationActionConfirms_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
|
||||
ActivityCronService_ProcessLevelRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessLevelRewardBatch"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -277,6 +277,16 @@ message ListRoleInvitationsResponse {
|
||||
repeated RoleInvitation invitations = 1;
|
||||
}
|
||||
|
||||
message GetRoleInvitationRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 actor_user_id = 2;
|
||||
int64 invitation_id = 3;
|
||||
}
|
||||
|
||||
message GetRoleInvitationResponse {
|
||||
RoleInvitation invitation = 1;
|
||||
}
|
||||
|
||||
message ProcessRoleInvitationRequest {
|
||||
RequestMeta meta = 1;
|
||||
string command_id = 2;
|
||||
@ -513,6 +523,7 @@ service UserHostService {
|
||||
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
|
||||
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
|
||||
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
|
||||
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
|
||||
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
|
||||
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
||||
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
||||
|
||||
@ -30,6 +30,7 @@ const (
|
||||
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
|
||||
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
|
||||
UserHostService_ListRoleInvitations_FullMethodName = "/hyapp.user.v1.UserHostService/ListRoleInvitations"
|
||||
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
|
||||
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
|
||||
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
||||
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
|
||||
@ -59,6 +60,7 @@ type UserHostServiceClient interface {
|
||||
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
|
||||
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
|
||||
ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error)
|
||||
GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
|
||||
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
|
||||
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
|
||||
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
|
||||
@ -189,6 +191,16 @@ func (c *userHostServiceClient) ListRoleInvitations(ctx context.Context, in *Lis
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRoleInvitationResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_GetRoleInvitation_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ProcessRoleInvitationResponse)
|
||||
@ -306,6 +318,7 @@ type UserHostServiceServer interface {
|
||||
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
|
||||
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
|
||||
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
|
||||
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
|
||||
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
|
||||
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
|
||||
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
|
||||
@ -359,6 +372,9 @@ func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListB
|
||||
func (UnimplementedUserHostServiceServer) ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRoleInvitations not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetRoleInvitation not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
|
||||
}
|
||||
@ -608,6 +624,24 @@ func _UserHostService_ListRoleInvitations_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoleInvitationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).GetRoleInvitation(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_GetRoleInvitation_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).GetRoleInvitation(ctx, req.(*GetRoleInvitationRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ProcessRoleInvitationRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -839,6 +873,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListRoleInvitations",
|
||||
Handler: _UserHostService_ListRoleInvitations_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoleInvitation",
|
||||
Handler: _UserHostService_GetRoleInvitation_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessRoleInvitation",
|
||||
Handler: _UserHostService_ProcessRoleInvitation_Handler,
|
||||
|
||||
477
docs/flutter对接/通用确认消息Flutter对接.md
Normal file
477
docs/flutter对接/通用确认消息Flutter对接.md
Normal file
@ -0,0 +1,477 @@
|
||||
# 通用确认消息 Flutter 对接
|
||||
|
||||
本文定义 Flutter 对接 `im_confirm` 私聊确认消息所需的 IM 解析、HTTP 接口、状态刷新和按钮置灰规则。业务类型首版是 `host / agency / bd` 身份邀请,Flutter 只做通用展示,不写业务分支。
|
||||
|
||||
## 通用约定
|
||||
|
||||
### Base URL
|
||||
|
||||
```text
|
||||
https://{api_host}
|
||||
```
|
||||
|
||||
本地开发:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization: Bearer {access_token}` | 是 | 登录后的 App token |
|
||||
| `Content-Type: application/json` | POST 必填 | 请求体 JSON |
|
||||
| `X-App-Code` | 否 | 多 App 包场景可传,通常由 gateway 解析 |
|
||||
|
||||
### 响应 Envelope
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "CONFLICT",
|
||||
"message": "conflict",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
## IM 消息格式
|
||||
|
||||
腾讯 IM C2C 自定义消息:
|
||||
|
||||
| TIM 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `Desc` | `im_confirm` |
|
||||
| `Ext` | `im_confirm` |
|
||||
| `Data` | JSON 字符串 |
|
||||
|
||||
`Data` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "im_confirm",
|
||||
"version": 1,
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"msg": "Alice has invited you to join ABC guild",
|
||||
"status": "pending",
|
||||
"actions": [
|
||||
{
|
||||
"action": "reject",
|
||||
"label": "Reject",
|
||||
"style": "secondary"
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"label": "Accept",
|
||||
"style": "primary"
|
||||
}
|
||||
],
|
||||
"created_at_ms": 1710000000000,
|
||||
"expire_at_ms": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `type` | string | 固定 `im_confirm` |
|
||||
| `version` | int | 当前为 `1` |
|
||||
| `confirm_id` | string | 确认消息 ID,后续接口只传这个 |
|
||||
| `business_type` | string | 业务类型,只用于日志和埋点 |
|
||||
| `business_subtype` | string | 业务子类型,只用于展示或埋点 |
|
||||
| `msg` | string | 气泡文案 |
|
||||
| `status` | string | 初始状态,一般为 `pending` |
|
||||
| `actions` | array | 按钮配置 |
|
||||
| `created_at_ms` | int64 | 创建时间 |
|
||||
| `expire_at_ms` | int64 | 过期时间,可能为 0 |
|
||||
|
||||
Flutter 不要把 `business_type`、`business_subtype` 当成接口参数。确认接口只需要 `confirm_id`。
|
||||
|
||||
## 状态含义
|
||||
|
||||
| 状态 | Flutter 展示 |
|
||||
| --- | --- |
|
||||
| `pending` | 展示可点击按钮 |
|
||||
| `processing` | 按钮置灰,显示处理中或本地 loading |
|
||||
| `accepted` | 按钮置灰,接受态 |
|
||||
| `rejected` | 按钮置灰,拒绝态 |
|
||||
| `cancelled` | 按钮置灰,已取消 |
|
||||
| `expired` | 按钮置灰,已过期 |
|
||||
| `failed` | 按钮置灰,可提示稍后重试或联系客服 |
|
||||
|
||||
按钮是否可点只看最新状态是否为 `pending`。
|
||||
|
||||
## 接口 1:接受
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/{confirm_id}/accept
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "cfm_123_accept_001"
|
||||
}
|
||||
```
|
||||
|
||||
返回值 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "accepted",
|
||||
"processed_action": "accept",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:用户点击 `actions.action=accept` 的按钮时调用。
|
||||
|
||||
## 接口 2:拒绝
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/{confirm_id}/reject
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "cfm_123_reject_001",
|
||||
"reason": ""
|
||||
}
|
||||
```
|
||||
|
||||
返回值 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "rejected",
|
||||
"processed_action": "reject",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:用户点击 `actions.action=reject` 的按钮时调用。
|
||||
|
||||
## 接口 3:批量刷新状态
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/status
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_ids": ["cfm_123", "cfm_456"]
|
||||
}
|
||||
```
|
||||
|
||||
返回值 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "accepted",
|
||||
"processed_action": "accept",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
使用场景:
|
||||
|
||||
- 打开会话时,收集当前会话里所有 `im_confirm.confirm_id` 后批量刷新。
|
||||
- App 从后台回到前台时,刷新当前可见页面的确认消息状态。
|
||||
- 接口返回的状态覆盖 IM 初始 `status`。
|
||||
|
||||
## 接口 4:按会话刷新状态
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
GET /api/v1/message/action-confirms?peer_user_id=10001&page_size=50
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `peer_user_id` | 是 | 当前会话对方用户 ID |
|
||||
| `page_size` | 否 | 默认 50,最大 100 |
|
||||
| `page_token` | 否 | 下一页游标 |
|
||||
|
||||
返回值 `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"sender_user_id": "10001",
|
||||
"receiver_user_id": "10002",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "pending",
|
||||
"processed_action": "",
|
||||
"processed_at_ms": 0,
|
||||
"created_at_ms": 1710000000000
|
||||
}
|
||||
],
|
||||
"next_page_token": ""
|
||||
}
|
||||
```
|
||||
|
||||
使用场景:
|
||||
|
||||
- 当前 IM SDK 本地消息无法快速遍历 `confirm_id` 时使用。
|
||||
- 当前用户由 token 决定,Flutter 只传对方 `peer_user_id`。
|
||||
|
||||
## Flutter 数据模型
|
||||
|
||||
```dart
|
||||
class ImConfirmMessage {
|
||||
final String confirmId;
|
||||
final String businessType;
|
||||
final String businessSubtype;
|
||||
final String msg;
|
||||
final String status;
|
||||
final List<ConfirmAction> actions;
|
||||
final int createdAtMs;
|
||||
final int expireAtMs;
|
||||
|
||||
ImConfirmMessage({
|
||||
required this.confirmId,
|
||||
required this.businessType,
|
||||
required this.businessSubtype,
|
||||
required this.msg,
|
||||
required this.status,
|
||||
required this.actions,
|
||||
required this.createdAtMs,
|
||||
required this.expireAtMs,
|
||||
});
|
||||
|
||||
bool get canOperate => status == 'pending';
|
||||
|
||||
factory ImConfirmMessage.fromJson(Map<String, dynamic> json) {
|
||||
return ImConfirmMessage(
|
||||
confirmId: (json['confirm_id'] ?? '').toString(),
|
||||
businessType: (json['business_type'] ?? '').toString(),
|
||||
businessSubtype: (json['business_subtype'] ?? '').toString(),
|
||||
msg: (json['msg'] ?? '').toString(),
|
||||
status: (json['status'] ?? 'pending').toString(),
|
||||
actions: ((json['actions'] as List?) ?? [])
|
||||
.map((item) => ConfirmAction.fromJson(Map<String, dynamic>.from(item as Map)))
|
||||
.toList(),
|
||||
createdAtMs: (json['created_at_ms'] as num?)?.toInt() ?? 0,
|
||||
expireAtMs: (json['expire_at_ms'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmAction {
|
||||
final String action;
|
||||
final String label;
|
||||
final String style;
|
||||
|
||||
ConfirmAction({
|
||||
required this.action,
|
||||
required this.label,
|
||||
required this.style,
|
||||
});
|
||||
|
||||
factory ConfirmAction.fromJson(Map<String, dynamic> json) {
|
||||
return ConfirmAction(
|
||||
action: (json['action'] ?? '').toString(),
|
||||
label: (json['label'] ?? '').toString(),
|
||||
style: (json['style'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmStatus {
|
||||
final String confirmId;
|
||||
final String status;
|
||||
final String processedAction;
|
||||
final int processedAtMs;
|
||||
|
||||
ConfirmStatus({
|
||||
required this.confirmId,
|
||||
required this.status,
|
||||
required this.processedAction,
|
||||
required this.processedAtMs,
|
||||
});
|
||||
|
||||
bool get canOperate => status == 'pending';
|
||||
|
||||
factory ConfirmStatus.fromJson(Map<String, dynamic> json) {
|
||||
return ConfirmStatus(
|
||||
confirmId: (json['confirm_id'] ?? '').toString(),
|
||||
status: (json['status'] ?? '').toString(),
|
||||
processedAction: (json['processed_action'] ?? '').toString(),
|
||||
processedAtMs: (json['processed_at_ms'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## IM 解析
|
||||
|
||||
```dart
|
||||
ImConfirmMessage? parseImConfirmCustomData(String data) {
|
||||
final dynamic decoded = jsonDecode(data);
|
||||
if (decoded is! Map) return null;
|
||||
final map = Map<String, dynamic>.from(decoded);
|
||||
if (map['type'] != 'im_confirm') return null;
|
||||
final confirmId = (map['confirm_id'] ?? '').toString();
|
||||
if (confirmId.isEmpty) return null;
|
||||
return ImConfirmMessage.fromJson(map);
|
||||
}
|
||||
```
|
||||
|
||||
解析规则:
|
||||
|
||||
- 只处理 `type == im_confirm`。
|
||||
- `confirm_id` 为空时按普通不支持消息处理。
|
||||
- `msg` 直接展示,不在 Flutter 端拼接业务文案。
|
||||
- `actions` 为空时只展示文案,不展示按钮。
|
||||
|
||||
## API 调用
|
||||
|
||||
```dart
|
||||
String newConfirmCommandId(String confirmId, String action) {
|
||||
return '${confirmId}_${action}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
Future<ConfirmStatus> acceptConfirm(String confirmId) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/v1/message/action-confirms/$confirmId/accept'),
|
||||
headers: authJsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'command_id': newConfirmCommandId(confirmId, 'accept'),
|
||||
}),
|
||||
);
|
||||
return parseConfirmStatusEnvelope(response);
|
||||
}
|
||||
|
||||
Future<ConfirmStatus> rejectConfirm(String confirmId, {String reason = ''}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/v1/message/action-confirms/$confirmId/reject'),
|
||||
headers: authJsonHeaders(),
|
||||
body: jsonEncode({
|
||||
'command_id': newConfirmCommandId(confirmId, 'reject'),
|
||||
'reason': reason,
|
||||
}),
|
||||
);
|
||||
return parseConfirmStatusEnvelope(response);
|
||||
}
|
||||
```
|
||||
|
||||
`command_id` 每次点击生成一个新值。网络重试同一次请求时可以复用同一个 `command_id`。
|
||||
|
||||
## 状态刷新
|
||||
|
||||
```dart
|
||||
Future<Map<String, ConfirmStatus>> batchRefreshConfirmStatus(List<String> confirmIds) async {
|
||||
final ids = confirmIds.where((id) => id.isNotEmpty).toSet().toList();
|
||||
if (ids.isEmpty) return {};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/v1/message/action-confirms/status'),
|
||||
headers: authJsonHeaders(),
|
||||
body: jsonEncode({'confirm_ids': ids}),
|
||||
);
|
||||
final data = parseEnvelopeData(response);
|
||||
final items = (data['items'] as List? ?? []);
|
||||
return {
|
||||
for (final item in items)
|
||||
ConfirmStatus.fromJson(Map<String, dynamic>.from(item as Map)).confirmId:
|
||||
ConfirmStatus.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
刷新时机:
|
||||
|
||||
- 进入 IM 会话页。
|
||||
- App resume。
|
||||
- 收到新的 `im_confirm`。
|
||||
- 接受或拒绝接口返回 `CONFLICT`。
|
||||
|
||||
## UI 规则
|
||||
|
||||
- 气泡文案展示 `msg`。
|
||||
- `status == pending` 时展示 `actions` 里的按钮。
|
||||
- `status != pending` 时按钮置灰,不能再次点击。
|
||||
- 本地发起请求后,当前按钮进入 loading,同一条消息两个按钮都置灰。
|
||||
- 接口成功后,用返回状态覆盖本地状态。
|
||||
- 接口失败但不是鉴权错误时,调用状态接口刷新一次。
|
||||
|
||||
按钮样式建议:
|
||||
|
||||
| style | 展示 |
|
||||
| --- | --- |
|
||||
| `primary` | 主按钮,例如绿色 Accept |
|
||||
| `secondary` | 次按钮,例如描边 Reject |
|
||||
|
||||
Flutter 不要根据 `business_subtype` 写死按钮文案,按钮文案以 `actions.label` 为准。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| code | Flutter 处理 |
|
||||
| --- | --- |
|
||||
| `UNAUTHORIZED` | 重新登录 |
|
||||
| `PERMISSION_DENIED` | 置灰并提示无权限 |
|
||||
| `NOT_FOUND` | 置灰,消息已失效 |
|
||||
| `CONFLICT` | 调状态接口刷新,按最新状态置灰 |
|
||||
| `UPSTREAM_ERROR` | 解除 loading,允许稍后重试 |
|
||||
| `INTERNAL_ERROR` | 解除 loading,提示稍后重试 |
|
||||
|
||||
重复点击、跨设备已处理、旧消息重新打开,都以状态接口返回为准。
|
||||
|
||||
## 对接检查
|
||||
|
||||
- 收到 `im_confirm` 后能渲染文案和两个按钮。
|
||||
- 点击 Accept 后调用接受接口,成功后按钮置灰。
|
||||
- 点击 Reject 后调用拒绝接口,成功后按钮置灰。
|
||||
- 同一条消息重复点击不会重复请求;如果请求已经发出,两个按钮都置灰。
|
||||
- App 重新进入会话后,通过状态接口刷新旧消息状态。
|
||||
- 另一个设备处理后,本设备 resume 能刷新为终态。
|
||||
- `CONFLICT` 时不弹业务成功,只刷新状态后展示最终状态。
|
||||
422
docs/通用确认消息方案.md
Normal file
422
docs/通用确认消息方案.md
Normal file
@ -0,0 +1,422 @@
|
||||
# 通用确认消息方案
|
||||
|
||||
本文定义 App 私聊中可操作确认消息的后端方案。首个业务场景是 `host / agency / bd` 身份邀请,后续其他业务只新增 `business_type` 处理器,不改变 Flutter 的通用消息渲染和确认接口。
|
||||
|
||||
## 目标
|
||||
|
||||
- 腾讯云 IM 私聊里支持通用确认消息,App 可以展示文案和 `Accept / Reject` 等操作按钮。
|
||||
- App 点击按钮后只提交 `confirm_id` 和动作,不提交业务参数。
|
||||
- 后端根据 `confirm_id` 查出业务类型、业务主键、发送人、接收人和当前状态,再调用对应 owner service 执行业务。
|
||||
- 处理成功后确认消息进入终态,App 再次渲染同一条 IM 时按钮置灰。
|
||||
- 当前用户只能处理发给自己的确认消息,不能替别人确认,也不能枚举别人的消息状态。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不用 `confirm` 表替代业务事实表。`role_invitations.status` 仍然是身份邀请的权威状态。
|
||||
- 不让客户端传 `business_type`、`business_id`、`sender_user_id` 或 `receiver_user_id` 来决定业务行为。
|
||||
- 不把确认消息做成房间状态、连接态或腾讯云 IM 会话状态的 owner。
|
||||
- 本方案不包含 `bd_leader` 邀请,当前只覆盖 `host / agency / bd`。
|
||||
|
||||
## 服务边界
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `gateway-service` | 提供 `/api/v1` HTTP 入口,透传当前登录用户和 `app_code`,不直接写确认表 |
|
||||
| `message action module` | 确认消息 owner,首版落在 `activity-service/internal/service/message`;负责确认表、状态机、幂等和业务分发 |
|
||||
| `user-service` | `host / agency / bd` 业务 owner,负责 `role_invitations` 和 `ProcessRoleInvitation` |
|
||||
| `notice-service` | 只负责把确认消息投递到腾讯云 IM C2C,不执行确认业务 |
|
||||
| 腾讯云 IM | 私聊消息、离线、漫游和客户端长连接 |
|
||||
|
||||
首版物理落点建议:
|
||||
|
||||
- 确认表放在 `hyapp_activity`,由 `activity-service` 的 message 模块管理。
|
||||
- `gateway-service` 新增 action confirm HTTP handler,内部调用 activity-service gRPC。
|
||||
- `notice-service` 消费 message action outbox,发送腾讯 IM C2C 自定义消息。
|
||||
|
||||
## 总流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Sender App
|
||||
participant Gateway as gateway-service
|
||||
participant User as user-service
|
||||
participant UserDB as hyapp_user
|
||||
participant Message as activity-service message module
|
||||
participant MsgDB as hyapp_activity
|
||||
participant Notice as notice-service
|
||||
participant TIM as Tencent IM
|
||||
participant B as Receiver App
|
||||
|
||||
A->>Gateway: 创建 host/agency/bd 邀请
|
||||
Gateway->>User: InviteHost / InviteAgency / InviteBD
|
||||
User->>UserDB: tx 写 role_invitations + user_outbox
|
||||
User-->>Gateway: invitation
|
||||
Gateway-->>A: invitation
|
||||
Message->>UserDB: consume RoleInvitationCreated
|
||||
Message->>MsgDB: 创建 message_action_confirms + message_action_outbox
|
||||
Notice->>MsgDB: consume ConfirmMessageCreated
|
||||
Notice->>TIM: C2C TIMCustomElem(type=im_confirm)
|
||||
TIM-->>B: IM 确认消息
|
||||
B->>Gateway: POST /api/v1/message/action-confirms/{confirm_id}/accept
|
||||
Gateway->>Message: AcceptActionConfirm
|
||||
Message->>MsgDB: pending -> processing
|
||||
Message->>User: ProcessRoleInvitation(action=accept)
|
||||
User->>UserDB: tx 推进 role_invitations 终态
|
||||
Message->>MsgDB: processing -> accepted
|
||||
Gateway-->>B: confirm status accepted
|
||||
```
|
||||
|
||||
## 数据表
|
||||
|
||||
### message_action_confirms
|
||||
|
||||
确认消息的当前状态表。
|
||||
|
||||
```sql
|
||||
CREATE TABLE message_action_confirms (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
confirm_id VARCHAR(96) NOT NULL,
|
||||
business_type VARCHAR(64) NOT NULL,
|
||||
business_subtype VARCHAR(64) NOT NULL DEFAULT '',
|
||||
business_id VARCHAR(96) NOT NULL,
|
||||
sender_user_id BIGINT NOT NULL,
|
||||
receiver_user_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
message_text VARCHAR(1024) NOT NULL,
|
||||
actions_json JSON NOT NULL,
|
||||
payload_json JSON NULL,
|
||||
source_name VARCHAR(64) NOT NULL,
|
||||
source_event_id VARCHAR(128) NOT NULL,
|
||||
processing_action VARCHAR(32) NOT NULL DEFAULT '',
|
||||
processing_command_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
locked_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
lock_until_ms BIGINT NOT NULL DEFAULT 0,
|
||||
processed_action VARCHAR(32) NOT NULL DEFAULT '',
|
||||
processed_command_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
processed_by_user_id BIGINT NULL,
|
||||
processed_at_ms BIGINT NULL,
|
||||
expire_at_ms BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, confirm_id),
|
||||
UNIQUE KEY uk_confirm_source_event (app_code, source_name, source_event_id),
|
||||
KEY idx_confirm_receiver_status (app_code, receiver_user_id, status, created_at_ms),
|
||||
KEY idx_confirm_peer (app_code, receiver_user_id, sender_user_id, created_at_ms),
|
||||
KEY idx_confirm_business (app_code, business_type, business_id)
|
||||
);
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `confirm_id` 由服务端生成,例如 `cfm_xxx`。
|
||||
- `business_type` 首版支持 `role_invitation`。
|
||||
- `business_subtype` 首版支持 `host`、`agency`、`bd`。
|
||||
- `business_id` 对 `role_invitation` 来说就是 `role_invitations.invitation_id`。
|
||||
- `sender_user_id` 是邀请发起人,`receiver_user_id` 是目标用户。
|
||||
- `message_text` 是 IM 展示快照,业务判断不能依赖它。
|
||||
- `actions_json` 首版为 `["accept","reject"]`。
|
||||
- `payload_json` 只放展示扩展,例如邀请人昵称、公会名、头像等。
|
||||
- `source_name + source_event_id` 用于从 owner outbox 重试时幂等创建确认消息。
|
||||
|
||||
### message_action_outbox
|
||||
|
||||
确认消息投递事件表,由 `notice-service` 消费后发腾讯 IM。
|
||||
|
||||
```sql
|
||||
CREATE TABLE message_action_outbox (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
confirm_id VARCHAR(96) NOT NULL,
|
||||
receiver_user_id BIGINT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(512) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_message_action_outbox_status (app_code, status, created_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
首版事件:
|
||||
|
||||
- `ConfirmMessageCreated`:发送一条新的 `im_confirm`。
|
||||
- 后续如果需要跨设备实时置灰,可新增 `ConfirmMessageProcessed`,投递状态变更 IM。首版可以通过状态接口刷新。
|
||||
|
||||
## 状态机
|
||||
|
||||
| 状态 | 说明 |
|
||||
| --- | --- |
|
||||
| `pending` | 待处理,App 展示可点击按钮 |
|
||||
| `processing` | 后端正在执行,App 按钮置灰并显示处理中 |
|
||||
| `accepted` | 已接受,终态 |
|
||||
| `rejected` | 已拒绝,终态 |
|
||||
| `cancelled` | 业务方取消,终态 |
|
||||
| `expired` | 超时未处理,终态 |
|
||||
| `failed` | 后端处理失败且需要人工排查,终态 |
|
||||
|
||||
状态推进规则:
|
||||
|
||||
- 只有 `receiver_user_id == 当前登录用户` 才能接受或拒绝。
|
||||
- `pending` 才能进入 `processing`。
|
||||
- `processing` 通过 `locked_by + lock_until_ms` 防止重复点击并发执行。
|
||||
- owner service 成功后,`processing` 进入 `accepted` 或 `rejected`。
|
||||
- 如果服务在 owner service 成功后、confirm 状态更新前崩溃,下一次同 `confirm_id` 请求必须用 `business_type + business_id` 查询 owner 当前事实,修复 confirm 状态。
|
||||
|
||||
## 业务类型
|
||||
|
||||
### role_invitation
|
||||
|
||||
首版处理 `host / agency / bd`。
|
||||
|
||||
| subtype | business_id | accept 动作 | reject 动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| `host` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |
|
||||
| `agency` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |
|
||||
| `bd` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |
|
||||
|
||||
`role_invitation` 的最终状态以 user-service 为准。confirm 模块只保存按钮状态和业务路由。
|
||||
|
||||
## HTTP 接口
|
||||
|
||||
所有接口都走 `/api/v1` envelope。
|
||||
|
||||
### 接受确认消息
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/{confirm_id}/accept
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "cfm_123_accept_001"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "accepted",
|
||||
"processed_action": "accept",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:收到 `type=im_confirm` 且 `confirm_id=cfm_123` 的消息后,点击 `accept` 调此接口。
|
||||
|
||||
### 拒绝确认消息
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/{confirm_id}/reject
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "cfm_123_reject_001",
|
||||
"reason": ""
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "rejected",
|
||||
"processed_action": "reject",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:收到 `type=im_confirm` 且 `confirm_id=cfm_123` 的消息后,点击 `reject` 调此接口。
|
||||
|
||||
### 批量查询确认状态
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
POST /api/v1/message/action-confirms/status
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_ids": ["cfm_123", "cfm_456"]
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "accepted",
|
||||
"processed_action": "accept",
|
||||
"processed_at_ms": 1710000001000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:App 打开会话或回到前台时,收集当前页面内的 `confirm_id` 后调用该接口刷新按钮状态。
|
||||
|
||||
### 按会话查询确认状态
|
||||
|
||||
地址:
|
||||
|
||||
```text
|
||||
GET /api/v1/message/action-confirms?peer_user_id=10001&page_size=50
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `peer_user_id` | 是 | 对方用户 ID;当前用户从 token 取,不能由客户端传 |
|
||||
| `page_size` | 否 | 默认 50,最大 100 |
|
||||
| `page_token` | 否 | 分页游标 |
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"confirm_id": "cfm_123",
|
||||
"sender_user_id": "10001",
|
||||
"receiver_user_id": "10002",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"business_id": "987",
|
||||
"status": "pending",
|
||||
"processed_action": "",
|
||||
"processed_at_ms": 0,
|
||||
"created_at_ms": 1710000000000
|
||||
}
|
||||
],
|
||||
"next_page_token": ""
|
||||
}
|
||||
```
|
||||
|
||||
相关 IM:App 如果无法从本地消息列表快速收集 `confirm_id`,可以按当前会话刷新最近确认消息状态。
|
||||
|
||||
## IM 负载
|
||||
|
||||
腾讯 IM 使用 C2C `TIMCustomElem`。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "im_confirm",
|
||||
"version": 1,
|
||||
"confirm_id": "cfm_123",
|
||||
"business_type": "role_invitation",
|
||||
"business_subtype": "host",
|
||||
"msg": "Alice has invited you to join ABC guild",
|
||||
"status": "pending",
|
||||
"actions": [
|
||||
{
|
||||
"action": "reject",
|
||||
"label": "Reject",
|
||||
"style": "secondary"
|
||||
},
|
||||
{
|
||||
"action": "accept",
|
||||
"label": "Accept",
|
||||
"style": "primary"
|
||||
}
|
||||
],
|
||||
"created_at_ms": 1710000000000,
|
||||
"expire_at_ms": 1710604800000
|
||||
}
|
||||
```
|
||||
|
||||
腾讯 IM 字段建议:
|
||||
|
||||
| TIM 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `Desc` | `im_confirm` |
|
||||
| `Ext` | `im_confirm` |
|
||||
| `Data` | 上面的 JSON 字符串 |
|
||||
|
||||
App 只依赖 `type`、`confirm_id`、`msg`、`status` 和 `actions`。`business_type` 只用于日志和埋点,不用于 App 决定业务接口。
|
||||
|
||||
## 创建消息规则
|
||||
|
||||
`host / agency / bd` 邀请创建成功后,user-service 写 `RoleInvitationCreated` outbox。payload 必须包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "RoleInvitationCreated",
|
||||
"invitation_id": "987",
|
||||
"invitation_type": "host",
|
||||
"status": "pending",
|
||||
"inviter_user_id": "10001",
|
||||
"target_user_id": "10002",
|
||||
"agency_name": "ABC guild",
|
||||
"created_at_ms": 1710000000000
|
||||
}
|
||||
```
|
||||
|
||||
message action worker 根据该 payload 创建:
|
||||
|
||||
- `business_type = role_invitation`
|
||||
- `business_subtype = invitation_type`
|
||||
- `business_id = invitation_id`
|
||||
- `sender_user_id = inviter_user_id`
|
||||
- `receiver_user_id = target_user_id`
|
||||
- `actions_json = ["accept","reject"]`
|
||||
|
||||
## 错误码
|
||||
|
||||
| HTTP | code | 场景 |
|
||||
| --- | --- | --- |
|
||||
| 400 | `INVALID_ARGUMENT` | `confirm_id`、`command_id` 或 action 参数非法 |
|
||||
| 401 | `UNAUTHORIZED` | 未登录或 token 无效 |
|
||||
| 403 | `PERMISSION_DENIED` | 当前用户不是接收人 |
|
||||
| 404 | `NOT_FOUND` | `confirm_id` 不存在 |
|
||||
| 409 | `CONFLICT` | 已处理、已过期、处理中或业务状态不允许操作 |
|
||||
| 502 | `UPSTREAM_ERROR` | owner service 不可用 |
|
||||
|
||||
## 开发顺序
|
||||
|
||||
1. 在 activity-service message 模块新增 `message_action_confirms` 和 `message_action_outbox`。
|
||||
2. 新增 `ActionConfirmService` gRPC:`AcceptActionConfirm`、`RejectActionConfirm`、`BatchGetActionConfirmStatus`、`ListConversationActionConfirms`。
|
||||
3. gateway 新增 HTTP 接口并调用 action confirm gRPC。
|
||||
4. user-service 邀请创建事务写 `RoleInvitationCreated` outbox。
|
||||
5. activity-service 增加 user outbox consumer,创建 confirm row 和 message action outbox。
|
||||
6. notice-service 增加 message action outbox consumer,发送 `im_confirm`。
|
||||
7. 补 `role_invitation` business handler,接受/拒绝时调用 user-service `ProcessRoleInvitation`。
|
||||
8. 补状态修复逻辑:confirm 仍 pending/processing 但 user-service 邀请已经终态时,以 user-service 状态回写 confirm。
|
||||
|
||||
## 验证
|
||||
|
||||
- 创建 Host 邀请后,能查到 `role_invitations.pending`、`message_action_confirms.pending` 和一条待投递 outbox。
|
||||
- notice-service 投递后,App 收到 `type=im_confirm`。
|
||||
- 接收人接受后,`role_invitations.status=accepted`,confirm 状态为 `accepted`。
|
||||
- 接收人拒绝后,`role_invitations.status=rejected`,confirm 状态为 `rejected`。
|
||||
- 非接收人调用接受/拒绝接口返回 `PERMISSION_DENIED`。
|
||||
- 重复点击同一个动作返回同一个终态,不重复创建业务事实。
|
||||
- 已接受后再拒绝返回 `CONFLICT`,状态接口返回 `accepted`。
|
||||
63
pkg/messagemq/messages.go
Normal file
63
pkg/messagemq/messages.go
Normal file
@ -0,0 +1,63 @@
|
||||
// Package messagemq defines RocketMQ payloads owned by activity-service message modules.
|
||||
package messagemq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeActionOutboxEvent = "message_action_outbox_event"
|
||||
|
||||
TagMessageActionOutboxEvent = "message_action_outbox_event"
|
||||
)
|
||||
|
||||
// ActionOutboxMessage is the MQ representation of one committed message_action_outbox fact.
|
||||
type ActionOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
ConfirmID string `json:"confirm_id"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
}
|
||||
|
||||
// EncodeActionOutboxMessage serializes a confirm-message fact for notice-service delivery.
|
||||
func EncodeActionOutboxMessage(message ActionOutboxMessage) ([]byte, error) {
|
||||
message.MessageType = MessageTypeActionOutboxEvent
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.ConfirmID) == "" ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return nil, errors.New("message action outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
// DecodeActionOutboxMessage validates one confirm-message MQ body.
|
||||
func DecodeActionOutboxMessage(body []byte) (ActionOutboxMessage, error) {
|
||||
var message ActionOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return ActionOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeActionOutboxEvent {
|
||||
return ActionOutboxMessage{}, errors.New("unexpected message action outbox message_type")
|
||||
}
|
||||
if strings.TrimSpace(message.AppCode) == "" ||
|
||||
strings.TrimSpace(message.EventID) == "" ||
|
||||
strings.TrimSpace(message.EventType) == "" ||
|
||||
strings.TrimSpace(message.ConfirmID) == "" ||
|
||||
message.OccurredAtMS <= 0 {
|
||||
return ActionOutboxMessage{}, errors.New("message action outbox message is incomplete")
|
||||
}
|
||||
if strings.TrimSpace(message.PayloadJSON) == "" {
|
||||
message.PayloadJSON = "{}"
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
@ -67,7 +67,7 @@ var catalog = map[Code]Spec{
|
||||
codes.FailedPrecondition,
|
||||
httpStatusConflict,
|
||||
DeviceAlreadyRegistered,
|
||||
"conflict",
|
||||
"device already registered",
|
||||
),
|
||||
|
||||
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
||||
|
||||
@ -86,6 +86,14 @@ func TestCatalogMappings(t *testing.T) {
|
||||
publicCode: string(CountryChangeCooldown),
|
||||
publicMessage: "country change is cooling down",
|
||||
},
|
||||
{
|
||||
name: "registered device conflict exposes concrete login reason",
|
||||
code: DeviceAlreadyRegistered,
|
||||
grpcCode: codes.FailedPrecondition,
|
||||
httpStatus: httpStatusConflict,
|
||||
publicCode: string(DeviceAlreadyRegistered),
|
||||
publicMessage: "device already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@ -25,6 +25,7 @@ TOPICS=(
|
||||
"hyapp_robot_room_outbox"
|
||||
"hyapp_room_rocket_launch"
|
||||
"hyapp_user_outbox"
|
||||
"hyapp_message_action_outbox"
|
||||
"hyapp_game_outbox"
|
||||
)
|
||||
|
||||
|
||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
message_action_confirm_worker:
|
||||
enabled: true
|
||||
outbox_poll_interval: "1s"
|
||||
outbox_batch_size: 100
|
||||
outbox_lock_ttl: "30s"
|
||||
outbox_max_retry: 8
|
||||
publish_timeout: "5s"
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -82,7 +89,14 @@ rocketmq:
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
|
||||
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
producer_group: "hyapp-activity-message-action-outbox"
|
||||
send_timeout: "5s"
|
||||
retry: 2
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
message_action_confirm_worker:
|
||||
enabled: true
|
||||
outbox_poll_interval: "1s"
|
||||
outbox_batch_size: 100
|
||||
outbox_lock_ttl: "30s"
|
||||
outbox_max_retry: 8
|
||||
publish_timeout: "5s"
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -82,7 +89,14 @@ rocketmq:
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
|
||||
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
producer_group: "hyapp-activity-message-action-outbox"
|
||||
send_timeout: "5s"
|
||||
retry: 2
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
||||
enabled: true
|
||||
task_event_worker:
|
||||
enabled: true
|
||||
message_action_confirm_worker:
|
||||
enabled: true
|
||||
outbox_poll_interval: "1s"
|
||||
outbox_batch_size: 100
|
||||
outbox_lock_ttl: "30s"
|
||||
outbox_max_retry: 8
|
||||
publish_timeout: "5s"
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -82,7 +89,14 @@ rocketmq:
|
||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
|
||||
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
producer_group: "hyapp-activity-message-action-outbox"
|
||||
send_timeout: "5s"
|
||||
retry: 2
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -1201,6 +1201,55 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs (
|
||||
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_action_confirms (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
|
||||
confirm_id VARCHAR(96) NOT NULL COMMENT 'App 操作确认 ID',
|
||||
source_name VARCHAR(64) NOT NULL COMMENT '来源事实名,例如 user_outbox',
|
||||
source_event_id VARCHAR(128) NOT NULL COMMENT '来源 outbox event_id',
|
||||
business_type VARCHAR(64) NOT NULL COMMENT '业务类型,例如 role_invitation',
|
||||
business_subtype VARCHAR(64) NOT NULL COMMENT '业务子类型,例如 host/agency/bd',
|
||||
business_id VARCHAR(128) NOT NULL COMMENT '业务 owner 的事实 ID',
|
||||
sender_user_id BIGINT NOT NULL COMMENT 'IM 发送方用户 ID',
|
||||
receiver_user_id BIGINT NOT NULL COMMENT 'IM 接收方用户 ID',
|
||||
message_text VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'IM 展示文案快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'pending/processing/accepted/rejected/expired/canceled/failed',
|
||||
action VARCHAR(32) NOT NULL DEFAULT '' COMMENT '终态动作 accept/reject',
|
||||
command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '处理动作 command_id',
|
||||
processed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理完成 UTC epoch ms',
|
||||
locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'processing 锁持有者',
|
||||
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'processing 锁过期 UTC epoch ms',
|
||||
error_message VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次处理失败原因',
|
||||
metadata_json JSON NULL COMMENT '业务展示扩展快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
|
||||
PRIMARY KEY (app_code, confirm_id),
|
||||
UNIQUE KEY uk_message_action_source (app_code, source_name, source_event_id),
|
||||
KEY idx_message_action_receiver_status (app_code, receiver_user_id, status, created_at_ms, confirm_id),
|
||||
KEY idx_message_action_conversation (app_code, receiver_user_id, sender_user_id, created_at_ms, confirm_id),
|
||||
KEY idx_message_action_lock (app_code, status, lock_until_ms, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App IM 操作确认消息状态';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_action_outbox (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '确认消息 outbox 事件 ID',
|
||||
confirm_id VARCHAR(96) NOT NULL COMMENT '关联 confirm_id',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '事件类型,例如 ConfirmMessageCreated',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'pending/running/retryable/delivered/failed',
|
||||
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID',
|
||||
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期 UTC epoch ms',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递重试次数',
|
||||
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次投递 UTC epoch ms',
|
||||
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次投递失败原因',
|
||||
payload_json JSON NOT NULL COMMENT '投递给 notice-service 的确认 IM 快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
UNIQUE KEY uk_message_action_outbox_confirm (app_code, confirm_id, event_type),
|
||||
KEY idx_message_action_outbox_status_created (app_code, status, next_retry_at_ms, created_at_ms, event_id),
|
||||
KEY idx_message_action_outbox_lock (app_code, status, lock_until_ms, created_at_ms, event_id),
|
||||
KEY idx_message_action_outbox_retention (app_code, status, updated_at_ms, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='确认消息 IM 投递 outbox';
|
||||
|
||||
-- 本地开发必须开箱即可验证幸运礼物 v2 链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布版本。
|
||||
SET @lucky_seed_now_ms := 1779259000000;
|
||||
INSERT IGNORE INTO lucky_gift_rule_versions (
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
CREATE TABLE IF NOT EXISTS message_action_confirms (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
|
||||
confirm_id VARCHAR(96) NOT NULL COMMENT 'App 操作确认 ID',
|
||||
source_name VARCHAR(64) NOT NULL COMMENT '来源事实名,例如 user_outbox',
|
||||
source_event_id VARCHAR(128) NOT NULL COMMENT '来源 outbox event_id',
|
||||
business_type VARCHAR(64) NOT NULL COMMENT '业务类型,例如 role_invitation',
|
||||
business_subtype VARCHAR(64) NOT NULL COMMENT '业务子类型,例如 host/agency/bd',
|
||||
business_id VARCHAR(128) NOT NULL COMMENT '业务 owner 的事实 ID',
|
||||
sender_user_id BIGINT NOT NULL COMMENT 'IM 发送方用户 ID',
|
||||
receiver_user_id BIGINT NOT NULL COMMENT 'IM 接收方用户 ID',
|
||||
message_text VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'IM 展示文案快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'pending/processing/accepted/rejected/expired/canceled/failed',
|
||||
action VARCHAR(32) NOT NULL DEFAULT '' COMMENT '终态动作 accept/reject',
|
||||
command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '处理动作 command_id',
|
||||
processed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理完成 UTC epoch ms',
|
||||
locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'processing 锁持有者',
|
||||
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'processing 锁过期 UTC epoch ms',
|
||||
error_message VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次处理失败原因',
|
||||
metadata_json JSON NULL COMMENT '业务展示扩展快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
|
||||
PRIMARY KEY (app_code, confirm_id),
|
||||
UNIQUE KEY uk_message_action_source (app_code, source_name, source_event_id),
|
||||
KEY idx_message_action_receiver_status (app_code, receiver_user_id, status, created_at_ms, confirm_id),
|
||||
KEY idx_message_action_conversation (app_code, receiver_user_id, sender_user_id, created_at_ms, confirm_id),
|
||||
KEY idx_message_action_lock (app_code, status, lock_until_ms, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App IM 操作确认消息状态';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_action_outbox (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT '确认消息 outbox 事件 ID',
|
||||
confirm_id VARCHAR(96) NOT NULL COMMENT '关联 confirm_id',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT '事件类型,例如 ConfirmMessageCreated',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'pending/running/retryable/delivered/failed',
|
||||
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID',
|
||||
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期 UTC epoch ms',
|
||||
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递重试次数',
|
||||
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次投递 UTC epoch ms',
|
||||
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次投递失败原因',
|
||||
payload_json JSON NOT NULL COMMENT '投递给 notice-service 的确认 IM 快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
UNIQUE KEY uk_message_action_outbox_confirm (app_code, confirm_id, event_type),
|
||||
KEY idx_message_action_outbox_status_created (app_code, status, next_retry_at_ms, created_at_ms, event_id),
|
||||
KEY idx_message_action_outbox_lock (app_code, status, lock_until_ms, created_at_ms, event_id),
|
||||
KEY idx_message_action_outbox_retention (app_code, status, updated_at_ms, event_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='确认消息 IM 投递 outbox';
|
||||
153
services/activity-service/internal/app/action_confirm_mq.go
Normal file
153
services/activity-service/internal/app/action_confirm_mq.go
Normal file
@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/messagemq"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/usermq"
|
||||
"hyapp/services/activity-service/internal/config"
|
||||
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
||||
)
|
||||
|
||||
const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated"
|
||||
|
||||
func newMessageActionUserOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
|
||||
consumer, err := rocketmqx.NewConsumer(userOutboxMessageActionConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.UserOutbox.Topic, usermq.TagUserOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
event, appCode, ok, err := roleInvitationConfirmEventFromUserMessage(message.Body)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
_, _, err = services.actionConfirm.ConsumeRoleInvitationCreated(appcode.WithContext(ctx, appCode), event)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = consumer.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
return consumer, nil
|
||||
}
|
||||
|
||||
type roleInvitationCreatedPayload struct {
|
||||
InvitationID int64 `json:"invitation_id"`
|
||||
InvitationType string `json:"invitation_type"`
|
||||
InviterUserID int64 `json:"inviter_user_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
AgencyName string `json:"agency_name"`
|
||||
Inviter messagedomain.RoleInvitationUserSnapshot `json:"inviter"`
|
||||
Target messagedomain.RoleInvitationUserSnapshot `json:"target"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ParentBDUserID int64 `json:"parent_bd_user_id"`
|
||||
ParentLeaderUserID int64 `json:"parent_leader_user_id"`
|
||||
}
|
||||
|
||||
func roleInvitationConfirmEventFromUserMessage(body []byte) (messagedomain.RoleInvitationConfirmEvent, string, bool, error) {
|
||||
message, err := usermq.DecodeUserOutboxMessage(body)
|
||||
if err != nil {
|
||||
return messagedomain.RoleInvitationConfirmEvent{}, "", false, err
|
||||
}
|
||||
if message.EventType != userOutboxEventRoleInvitationCreated {
|
||||
return messagedomain.RoleInvitationConfirmEvent{}, message.AppCode, false, nil
|
||||
}
|
||||
var payload roleInvitationCreatedPayload
|
||||
if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil {
|
||||
return messagedomain.RoleInvitationConfirmEvent{}, message.AppCode, true, err
|
||||
}
|
||||
return messagedomain.RoleInvitationConfirmEvent{
|
||||
SourceEventID: message.EventID,
|
||||
InvitationID: payload.InvitationID,
|
||||
InvitationType: payload.InvitationType,
|
||||
InviterUserID: payload.InviterUserID,
|
||||
TargetUserID: payload.TargetUserID,
|
||||
AgencyName: payload.AgencyName,
|
||||
Inviter: payload.Inviter,
|
||||
Target: payload.Target,
|
||||
CreatedAtMS: firstPositiveMS(payload.CreatedAtMS, message.OccurredAtMS),
|
||||
RawPayloadJSON: message.PayloadJSON,
|
||||
ParentBDUserID: payload.ParentBDUserID,
|
||||
ParentLeaderUserID: payload.ParentLeaderUserID,
|
||||
}, message.AppCode, true, nil
|
||||
}
|
||||
|
||||
func (a *App) runMessageActionOutboxWorker(ctx context.Context) {
|
||||
options := a.messageActionWorkerOptions
|
||||
ticker := time.NewTicker(options.OutboxPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
processed, err := a.processMessageActionOutboxBatch(ctx)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logx.Error(ctx, "message_action_outbox_publish_failed", err, slog.String("node_id", a.workerNodeID))
|
||||
}
|
||||
if processed >= options.OutboxBatchSize {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processMessageActionOutboxBatch(ctx context.Context) (int, error) {
|
||||
options := a.messageActionWorkerOptions
|
||||
workerCtx := appcode.WithContext(ctx, appcode.Default)
|
||||
records, err := a.actionConfirm.ClaimPendingOutbox(workerCtx, options.OutboxBatchSize, options.OutboxLockTTL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, record := range records {
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout)
|
||||
err := a.publishMessageActionOutboxRecord(publishCtx, record)
|
||||
cancel()
|
||||
recordCtx := appcode.WithContext(context.Background(), record.AppCode)
|
||||
if err != nil {
|
||||
nextRetryAtMS := time.Now().UTC().Add(options.OutboxPollInterval).UnixMilli()
|
||||
if markErr := a.actionConfirm.MarkOutboxRetryable(recordCtx, record.EventID, err, nextRetryAtMS); markErr != nil {
|
||||
return len(records), markErr
|
||||
}
|
||||
return len(records), err
|
||||
}
|
||||
if err := a.actionConfirm.MarkOutboxDelivered(recordCtx, record.EventID); err != nil {
|
||||
return len(records), err
|
||||
}
|
||||
}
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (a *App) publishMessageActionOutboxRecord(ctx context.Context, record messagedomain.ActionConfirmOutbox) error {
|
||||
body, err := messagemq.EncodeActionOutboxMessage(messagemq.ActionOutboxMessage{
|
||||
AppCode: record.AppCode,
|
||||
EventID: record.EventID,
|
||||
EventType: record.EventType,
|
||||
ConfirmID: record.ConfirmID,
|
||||
PayloadJSON: record.PayloadJSON,
|
||||
OccurredAtMS: record.CreatedAtMS,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.messageActionProducer.SendSync(ctx, rocketmqx.Message{
|
||||
Topic: a.messageActionOutboxTopic,
|
||||
Tag: messagemq.TagMessageActionOutboxEvent,
|
||||
Keys: []string{record.EventID, record.ConfirmID},
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
func firstPositiveMS(values ...int64) int64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return time.Now().UTC().UnixMilli()
|
||||
}
|
||||
@ -21,32 +21,38 @@ import (
|
||||
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||
inviteactivityservice "hyapp/services/activity-service/internal/service/inviteactivity"
|
||||
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||
)
|
||||
|
||||
// App 装配 activity-service 的 gRPC 入口、健康检查、后台 worker 和外部连接。
|
||||
// 具体业务模块的创建和注册都放在独立文件里,避免启动入口继续膨胀成一个难维护的大函数。
|
||||
type App struct {
|
||||
server *grpc.Server
|
||||
listener net.Listener
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
broadcast *broadcastservice.Service
|
||||
luckyGift *luckygiftservice.Service
|
||||
firstRechargeReward *firstrechargeservice.Service
|
||||
cumulativeRecharge *cumulativerechargeservice.Service
|
||||
inviteActivityReward *inviteactivityservice.Service
|
||||
broadcastWorkerEnabled bool
|
||||
luckyGiftWorkerEnabled bool
|
||||
workerNodeID string
|
||||
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
userConn *grpc.ClientConn
|
||||
walletConn *grpc.ClientConn
|
||||
roomConn *grpc.ClientConn
|
||||
workers *serviceapp.BackgroundGroup
|
||||
closeOnce sync.Once
|
||||
server *grpc.Server
|
||||
listener net.Listener
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
broadcast *broadcastservice.Service
|
||||
luckyGift *luckygiftservice.Service
|
||||
firstRechargeReward *firstrechargeservice.Service
|
||||
cumulativeRecharge *cumulativerechargeservice.Service
|
||||
inviteActivityReward *inviteactivityservice.Service
|
||||
actionConfirm *messageservice.ActionConfirmService
|
||||
broadcastWorkerEnabled bool
|
||||
luckyGiftWorkerEnabled bool
|
||||
messageActionWorkerEnabled bool
|
||||
workerNodeID string
|
||||
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
|
||||
messageActionWorkerOptions config.MessageActionConfirmWorkerConfig
|
||||
messageActionOutboxTopic string
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
messageActionProducer *rocketmqx.Producer
|
||||
userConn *grpc.ClientConn
|
||||
walletConn *grpc.ClientConn
|
||||
roomConn *grpc.ClientConn
|
||||
workers *serviceapp.BackgroundGroup
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// New 初始化 activity-service 应用。
|
||||
@ -61,8 +67,10 @@ func New(cfg config.Config) (*App, error) {
|
||||
var listener net.Listener
|
||||
var clients externalClients
|
||||
var mqConsumers []*rocketmqx.Consumer
|
||||
var messageActionProducer *rocketmqx.Producer
|
||||
cleanup := func() {
|
||||
shutdownConsumers(mqConsumers)
|
||||
servicemq.ShutdownProducers([]*rocketmqx.Producer{messageActionProducer})
|
||||
clients.Close()
|
||||
if listener != nil {
|
||||
_ = listener.Close()
|
||||
@ -94,6 +102,13 @@ func New(cfg config.Config) (*App, error) {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
if cfg.RocketMQ.MessageActionOutbox.Enabled {
|
||||
messageActionProducer, err = rocketmqx.NewProducer(messageActionProducerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
health, healthHTTP, err := newHealthServers(cfg, server, repository)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@ -101,25 +116,30 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
|
||||
return &App{
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: repository,
|
||||
broadcast: services.broadcast,
|
||||
luckyGift: services.luckyGift,
|
||||
firstRechargeReward: services.firstRechargeReward,
|
||||
cumulativeRecharge: services.cumulativeRecharge,
|
||||
inviteActivityReward: services.inviteActivityReward,
|
||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
||||
workerNodeID: cfg.NodeID,
|
||||
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
||||
mqConsumers: mqConsumers,
|
||||
userConn: clients.userConn,
|
||||
walletConn: clients.walletConn,
|
||||
roomConn: clients.roomConn,
|
||||
workers: serviceapp.NewBackground(context.Background()),
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: repository,
|
||||
broadcast: services.broadcast,
|
||||
luckyGift: services.luckyGift,
|
||||
firstRechargeReward: services.firstRechargeReward,
|
||||
cumulativeRecharge: services.cumulativeRecharge,
|
||||
inviteActivityReward: services.inviteActivityReward,
|
||||
actionConfirm: services.actionConfirm,
|
||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
||||
messageActionWorkerEnabled: cfg.MessageActionConfirmWorker.Enabled,
|
||||
workerNodeID: cfg.NodeID,
|
||||
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
||||
messageActionWorkerOptions: cfg.MessageActionConfirmWorker,
|
||||
messageActionOutboxTopic: cfg.RocketMQ.MessageActionOutbox.Topic,
|
||||
mqConsumers: mqConsumers,
|
||||
messageActionProducer: messageActionProducer,
|
||||
userConn: clients.userConn,
|
||||
walletConn: clients.walletConn,
|
||||
roomConn: clients.roomConn,
|
||||
workers: serviceapp.NewBackground(context.Background()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -141,6 +161,11 @@ func (a *App) Run() error {
|
||||
a.luckyGift.RunWorker(ctx, a.luckyGiftWorkerOptions)
|
||||
})
|
||||
}
|
||||
if a.messageActionWorkerEnabled && a.actionConfirm != nil && a.messageActionProducer != nil && a.workers != nil {
|
||||
a.workers.Go(func(ctx context.Context) {
|
||||
a.runMessageActionOutboxWorker(ctx)
|
||||
})
|
||||
}
|
||||
err := a.server.Serve(a.listener)
|
||||
if a.workers != nil {
|
||||
a.workers.StopAndWait()
|
||||
@ -201,9 +226,13 @@ func (a *App) closeHealthHTTP() {
|
||||
}
|
||||
|
||||
func (a *App) startMQ() error {
|
||||
if err := servicemq.StartProducers([]*rocketmqx.Producer{a.messageActionProducer}); err != nil {
|
||||
return err
|
||||
}
|
||||
return servicemq.StartConsumers(a.mqConsumers)
|
||||
}
|
||||
|
||||
func (a *App) shutdownMQ() {
|
||||
servicemq.ShutdownConsumers(a.mqConsumers)
|
||||
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.messageActionProducer})
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
||||
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(services.activity))
|
||||
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
|
||||
activityv1.RegisterMessageActionConfirmServiceServer(server, grpcserver.NewActionConfirmServer(services.actionConfirm))
|
||||
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
|
||||
// weekly-star 和 room-turnover 都依赖 wallet 发奖,所以这里必须把同一批已装配的 service 注入 cron server。
|
||||
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(services.message, services.growth, services.achievement, services.weeklyStar, services.roomTurnoverReward, services.agencyOpening, services.cpWeeklyRank))
|
||||
|
||||
@ -80,6 +80,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled {
|
||||
consumer, err := newMessageActionUserOutboxConsumer(cfg, services)
|
||||
if err != nil {
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||
consumer, err := newRedPacketWalletConsumer(cfg, services)
|
||||
if err != nil {
|
||||
@ -317,10 +325,31 @@ func userOutboxInviteActivityConsumerConfig(cfg config.RocketMQConfig) rocketmqx
|
||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.InviteActivityConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
func userOutboxMessageActionConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.MessageActionConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
|
||||
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
||||
}
|
||||
|
||||
func messageActionProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
|
||||
return rocketmqx.ProducerConfig{
|
||||
EndpointConfig: rocketmqx.EndpointConfig{
|
||||
NameServers: cfg.NameServers,
|
||||
NameServerDomain: cfg.NameServerDomain,
|
||||
AccessKey: cfg.AccessKey,
|
||||
SecretKey: cfg.SecretKey,
|
||||
SecurityToken: cfg.SecurityToken,
|
||||
Namespace: cfg.Namespace,
|
||||
VIPChannel: cfg.VIPChannel,
|
||||
},
|
||||
GroupName: cfg.MessageActionOutbox.ProducerGroup,
|
||||
SendTimeout: cfg.MessageActionOutbox.SendTimeout,
|
||||
Retry: cfg.MessageActionOutbox.Retry,
|
||||
}
|
||||
}
|
||||
|
||||
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
|
||||
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
|
||||
if cfg.RealtimeTopic != "" {
|
||||
|
||||
@ -31,6 +31,7 @@ import (
|
||||
type serviceBundle struct {
|
||||
activity *activityservice.Service
|
||||
message *messageservice.Service
|
||||
actionConfirm *messageservice.ActionConfirmService
|
||||
task *taskservice.Service
|
||||
registrationReward *registrationrewardservice.Service
|
||||
sevenDayCheckIn *sevendaycheckinservice.Service
|
||||
@ -55,6 +56,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
||||
|
||||
activitySvc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
|
||||
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(activityclient.NewGRPCUserTargetSource(clients.userConn)))
|
||||
actionConfirmSvc := messageservice.NewActionConfirm(messageservice.Config{NodeID: cfg.NodeID}, repository, activityclient.NewGRPCRoleInvitationClient(clients.userConn))
|
||||
taskSvc := taskservice.New(repository, walletClient)
|
||||
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
|
||||
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
|
||||
@ -106,6 +108,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
||||
return &serviceBundle{
|
||||
activity: activitySvc,
|
||||
message: messageSvc,
|
||||
actionConfirm: actionConfirmSvc,
|
||||
task: taskSvc,
|
||||
registrationReward: registrationRewardSvc,
|
||||
sevenDayCheckIn: sevenDayCheckInSvc,
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// GRPCRoleInvitationClient calls user-service because role invitation state remains owned by user-service.
|
||||
type GRPCRoleInvitationClient struct {
|
||||
client userv1.UserHostServiceClient
|
||||
}
|
||||
|
||||
func NewGRPCRoleInvitationClient(conn *grpc.ClientConn) *GRPCRoleInvitationClient {
|
||||
return &GRPCRoleInvitationClient{client: userv1.NewUserHostServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *GRPCRoleInvitationClient) ProcessRoleInvitation(ctx context.Context, input messageservice.ProcessRoleInvitationInput) (messageservice.RoleInvitationState, error) {
|
||||
resp, err := c.client.ProcessRoleInvitation(ctx, &userv1.ProcessRoleInvitationRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: "message-action-confirm-process-role-invitation",
|
||||
Caller: "activity-service",
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
},
|
||||
CommandId: input.CommandID,
|
||||
ActorUserId: input.ActorUserID,
|
||||
InvitationId: input.InvitationID,
|
||||
Action: input.Action,
|
||||
Reason: input.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return messageservice.RoleInvitationState{}, err
|
||||
}
|
||||
return roleInvitationStateFromProto(resp.GetInvitation()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCRoleInvitationClient) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (messageservice.RoleInvitationState, error) {
|
||||
resp, err := c.client.GetRoleInvitation(ctx, &userv1.GetRoleInvitationRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: "message-action-confirm-get-role-invitation",
|
||||
Caller: "activity-service",
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
},
|
||||
ActorUserId: actorUserID,
|
||||
InvitationId: invitationID,
|
||||
})
|
||||
if err != nil {
|
||||
return messageservice.RoleInvitationState{}, err
|
||||
}
|
||||
return roleInvitationStateFromProto(resp.GetInvitation()), nil
|
||||
}
|
||||
|
||||
func roleInvitationStateFromProto(invitation *userv1.RoleInvitation) messageservice.RoleInvitationState {
|
||||
if invitation == nil {
|
||||
return messageservice.RoleInvitationState{}
|
||||
}
|
||||
return messageservice.RoleInvitationState{
|
||||
InvitationID: invitation.GetInvitationId(),
|
||||
InvitationType: invitation.GetInvitationType(),
|
||||
Status: invitation.GetStatus(),
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,9 @@ type Config struct {
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Consumer ConsumerConfig `yaml:"consumer"`
|
||||
MessageInbox MessageInboxConfig `yaml:"message_inbox"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
// MessageActionConfirmWorker 控制通用确认消息的 user_outbox 消费和 IM outbox 发布。
|
||||
MessageActionConfirmWorker MessageActionConfirmWorkerConfig `yaml:"message_action_confirm_worker"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// ConsumerConfig 保存 room outbox 消费底座配置。
|
||||
@ -63,6 +65,16 @@ type MessageInboxConfig struct {
|
||||
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
|
||||
}
|
||||
|
||||
// MessageActionConfirmWorkerConfig 保存确认消息消费和 MQ 发布策略。
|
||||
type MessageActionConfirmWorkerConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
OutboxPollInterval time.Duration `yaml:"outbox_poll_interval"`
|
||||
OutboxBatchSize int `yaml:"outbox_batch_size"`
|
||||
OutboxLockTTL time.Duration `yaml:"outbox_lock_ttl"`
|
||||
OutboxMaxRetry int `yaml:"outbox_max_retry"`
|
||||
PublishTimeout time.Duration `yaml:"publish_timeout"`
|
||||
}
|
||||
|
||||
// FirstRechargeRewardWorkerConfig 保存钱包充值事实消费策略。
|
||||
type FirstRechargeRewardWorkerConfig struct {
|
||||
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
|
||||
@ -149,17 +161,18 @@ type BroadcastConfig struct {
|
||||
|
||||
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
|
||||
type RocketMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
||||
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
||||
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
||||
MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"`
|
||||
}
|
||||
|
||||
// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。
|
||||
@ -190,9 +203,19 @@ type UserOutboxMQConfig struct {
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
TaskConsumerGroup string `yaml:"task_consumer_group"`
|
||||
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
|
||||
MessageActionConsumerGroup string `yaml:"message_action_consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
// MessageActionOutboxMQConfig 控制确认消息 outbox 投递到 notice-service 的 MQ producer。
|
||||
type MessageActionOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ProducerGroup string `yaml:"producer_group"`
|
||||
SendTimeout time.Duration `yaml:"send_timeout"`
|
||||
Retry int `yaml:"retry"`
|
||||
}
|
||||
|
||||
// Default 返回本地默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
@ -220,6 +243,14 @@ func Default() Config {
|
||||
TaskEventWorker: TaskEventWorkerConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
MessageActionConfirmWorker: MessageActionConfirmWorkerConfig{
|
||||
Enabled: true,
|
||||
OutboxPollInterval: time.Second,
|
||||
OutboxBatchSize: 100,
|
||||
OutboxLockTTL: 30 * time.Second,
|
||||
OutboxMaxRetry: 8,
|
||||
PublishTimeout: 5 * time.Second,
|
||||
},
|
||||
LuckyGiftWorker: LuckyGiftWorkerConfig{
|
||||
Enabled: false,
|
||||
WorkerPollInterval: time.Second,
|
||||
@ -291,8 +322,16 @@ func defaultRocketMQConfig() RocketMQConfig {
|
||||
ConsumerGroup: "hyapp-activity-user-region-broadcast",
|
||||
TaskConsumerGroup: "hyapp-activity-task-user-outbox",
|
||||
InviteActivityConsumerGroup: "hyapp-activity-invite-user-outbox",
|
||||
MessageActionConsumerGroup: "hyapp-activity-message-action-user-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
MessageActionOutbox: MessageActionOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_message_action_outbox",
|
||||
ProducerGroup: "hyapp-activity-message-action-outbox",
|
||||
SendTimeout: 5 * time.Second,
|
||||
Retry: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,6 +425,21 @@ func Load(path string) (Config, error) {
|
||||
if cfg.LuckyGiftWorker.PublishTimeout <= 0 {
|
||||
cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.OutboxPollInterval <= 0 {
|
||||
cfg.MessageActionConfirmWorker.OutboxPollInterval = time.Second
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.OutboxBatchSize <= 0 {
|
||||
cfg.MessageActionConfirmWorker.OutboxBatchSize = 100
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.OutboxLockTTL <= 0 {
|
||||
cfg.MessageActionConfirmWorker.OutboxLockTTL = 30 * time.Second
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.OutboxMaxRetry <= 0 {
|
||||
cfg.MessageActionConfirmWorker.OutboxMaxRetry = 8
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.PublishTimeout <= 0 {
|
||||
cfg.MessageActionConfirmWorker.PublishTimeout = 5 * time.Second
|
||||
}
|
||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@ -397,6 +451,9 @@ func Load(path string) (Config, error) {
|
||||
if cfg.InviteActivityRewardWorker.Enabled && !cfg.RocketMQ.UserOutbox.Enabled {
|
||||
return Config{}, errors.New("invite activity reward worker requires rocketmq.user_outbox.enabled")
|
||||
}
|
||||
if cfg.MessageActionConfirmWorker.Enabled && (!cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.MessageActionOutbox.Enabled) {
|
||||
return Config{}, errors.New("message action confirm worker requires rocketmq.user_outbox.enabled and rocketmq.message_action_outbox.enabled")
|
||||
}
|
||||
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
|
||||
return Config{}, errors.New("lucky gift worker requires tencent_im.enabled")
|
||||
}
|
||||
@ -455,10 +512,25 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||
if cfg.UserOutbox.InviteActivityConsumerGroup = strings.TrimSpace(cfg.UserOutbox.InviteActivityConsumerGroup); cfg.UserOutbox.InviteActivityConsumerGroup == "" {
|
||||
cfg.UserOutbox.InviteActivityConsumerGroup = defaults.UserOutbox.InviteActivityConsumerGroup
|
||||
}
|
||||
if cfg.UserOutbox.MessageActionConsumerGroup = strings.TrimSpace(cfg.UserOutbox.MessageActionConsumerGroup); cfg.UserOutbox.MessageActionConsumerGroup == "" {
|
||||
cfg.UserOutbox.MessageActionConsumerGroup = defaults.UserOutbox.MessageActionConsumerGroup
|
||||
}
|
||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled {
|
||||
if cfg.MessageActionOutbox.Topic = strings.TrimSpace(cfg.MessageActionOutbox.Topic); cfg.MessageActionOutbox.Topic == "" {
|
||||
cfg.MessageActionOutbox.Topic = defaults.MessageActionOutbox.Topic
|
||||
}
|
||||
if cfg.MessageActionOutbox.ProducerGroup = strings.TrimSpace(cfg.MessageActionOutbox.ProducerGroup); cfg.MessageActionOutbox.ProducerGroup == "" {
|
||||
cfg.MessageActionOutbox.ProducerGroup = defaults.MessageActionOutbox.ProducerGroup
|
||||
}
|
||||
if cfg.MessageActionOutbox.SendTimeout <= 0 {
|
||||
cfg.MessageActionOutbox.SendTimeout = defaults.MessageActionOutbox.SendTimeout
|
||||
}
|
||||
if cfg.MessageActionOutbox.Retry < 0 {
|
||||
cfg.MessageActionOutbox.Retry = defaults.MessageActionOutbox.Retry
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
if cfg.Enabled {
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
package message
|
||||
|
||||
const (
|
||||
ActionConfirmBusinessRoleInvitation = "role_invitation"
|
||||
|
||||
ActionConfirmSourceUserOutbox = "user_outbox"
|
||||
|
||||
ActionConfirmEventCreated = "ConfirmMessageCreated"
|
||||
|
||||
ActionConfirmStatusPending = "pending"
|
||||
ActionConfirmStatusProcessing = "processing"
|
||||
ActionConfirmStatusAccepted = "accepted"
|
||||
ActionConfirmStatusRejected = "rejected"
|
||||
ActionConfirmStatusExpired = "expired"
|
||||
ActionConfirmStatusCanceled = "canceled"
|
||||
ActionConfirmStatusFailed = "failed"
|
||||
|
||||
ActionConfirmActionAccept = "accept"
|
||||
ActionConfirmActionReject = "reject"
|
||||
)
|
||||
|
||||
// ActionConfirm 是 IM 操作按钮的后端状态机;App 只能拿 confirm_id 操作,业务参数全部来自这行事实。
|
||||
type ActionConfirm struct {
|
||||
AppCode string
|
||||
ConfirmID string
|
||||
SourceName string
|
||||
SourceEventID string
|
||||
BusinessType string
|
||||
BusinessSubtype string
|
||||
BusinessID string
|
||||
SenderUserID int64
|
||||
ReceiverUserID int64
|
||||
MessageText string
|
||||
Status string
|
||||
Action string
|
||||
CommandID string
|
||||
ProcessedAtMS int64
|
||||
LockedBy string
|
||||
LockUntilMS int64
|
||||
ErrorMessage string
|
||||
MetadataJSON string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// ActionConfirmOutbox 是确认消息创建后交给 notice-service 投递 IM 的可靠事件箱。
|
||||
type ActionConfirmOutbox struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
ConfirmID string
|
||||
EventType string
|
||||
Status string
|
||||
WorkerID string
|
||||
LockUntilMS int64
|
||||
RetryCount int
|
||||
NextRetryAtMS int64
|
||||
LastError string
|
||||
PayloadJSON string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RoleInvitationConfirmEvent 是 user_outbox 中 RoleInvitationCreated 的最小投影。
|
||||
type RoleInvitationConfirmEvent struct {
|
||||
SourceEventID string
|
||||
InvitationID int64
|
||||
InvitationType string
|
||||
InviterUserID int64
|
||||
TargetUserID int64
|
||||
AgencyName string
|
||||
Inviter RoleInvitationUserSnapshot
|
||||
Target RoleInvitationUserSnapshot
|
||||
CreatedAtMS int64
|
||||
RawPayloadJSON string
|
||||
ParentBDUserID int64
|
||||
ParentLeaderUserID int64
|
||||
}
|
||||
|
||||
// RoleInvitationUserSnapshot 是生成 IM 展示文案时使用的用户资料快照,不作为权限依据。
|
||||
type RoleInvitationUserSnapshot struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
@ -0,0 +1,443 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
||||
)
|
||||
|
||||
const (
|
||||
actionConfirmDefaultLockTTL = 30 * time.Second
|
||||
actionConfirmDefaultLimit = 50
|
||||
actionConfirmMaxLimit = 100
|
||||
)
|
||||
|
||||
// ActionConfirmRepository is the durable boundary for confirm rows and their IM outbox.
|
||||
type ActionConfirmRepository interface {
|
||||
SaveActionConfirmWithOutbox(ctx context.Context, confirm messagedomain.ActionConfirm, outbox messagedomain.ActionConfirmOutbox) (bool, messagedomain.ActionConfirm, error)
|
||||
ClaimActionConfirm(ctx context.Context, confirmID string, actorUserID int64, action string, commandID string, workerID string, nowMS int64, lockUntilMS int64) (messagedomain.ActionConfirm, bool, error)
|
||||
CompleteActionConfirm(ctx context.Context, confirmID string, commandID string, status string, action string, processedAtMS int64) (messagedomain.ActionConfirm, error)
|
||||
ReleaseActionConfirm(ctx context.Context, confirmID string, commandID string, errorMessage string, nowMS int64) error
|
||||
BatchGetActionConfirms(ctx context.Context, userID int64, confirmIDs []string) ([]messagedomain.ActionConfirm, error)
|
||||
ListConversationActionConfirms(ctx context.Context, userID int64, peerUserID int64, limit int, cursorCreatedAtMS int64, cursorConfirmID string) ([]messagedomain.ActionConfirm, error)
|
||||
ClaimPendingActionConfirmOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, batchSize int) ([]messagedomain.ActionConfirmOutbox, error)
|
||||
MarkActionConfirmOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error
|
||||
MarkActionConfirmOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64, nowMS int64) error
|
||||
}
|
||||
|
||||
// RoleInvitationClient is the owner-service boundary used when a confirm action maps to a role invitation.
|
||||
type RoleInvitationClient interface {
|
||||
ProcessRoleInvitation(ctx context.Context, input ProcessRoleInvitationInput) (RoleInvitationState, error)
|
||||
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (RoleInvitationState, error)
|
||||
}
|
||||
|
||||
// ProcessRoleInvitationInput is the normalized owner-service command assembled from a confirm row.
|
||||
type ProcessRoleInvitationInput struct {
|
||||
CommandID string
|
||||
ActorUserID int64
|
||||
InvitationID int64
|
||||
Action string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RoleInvitationState is the small role-invitation projection needed to reconcile confirm status.
|
||||
type RoleInvitationState struct {
|
||||
InvitationID int64
|
||||
InvitationType string
|
||||
Status string
|
||||
}
|
||||
|
||||
// ActionConfirmService owns IM confirm status and delegates business mutations back to owner services.
|
||||
type ActionConfirmService struct {
|
||||
cfg Config
|
||||
repository ActionConfirmRepository
|
||||
roleInvitationClient RoleInvitationClient
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewActionConfirm creates the generic confirm-message service.
|
||||
func NewActionConfirm(cfg Config, repository ActionConfirmRepository, roleInvitationClient RoleInvitationClient) *ActionConfirmService {
|
||||
return &ActionConfirmService{cfg: cfg, repository: repository, roleInvitationClient: roleInvitationClient, now: time.Now}
|
||||
}
|
||||
|
||||
// SetClock injects deterministic time for tests.
|
||||
func (s *ActionConfirmService) SetClock(now func() time.Time) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
|
||||
// ConsumeRoleInvitationCreated materializes one user-service invitation fact into an actionable IM confirm.
|
||||
func (s *ActionConfirmService) ConsumeRoleInvitationCreated(ctx context.Context, event messagedomain.RoleInvitationConfirmEvent) (messagedomain.ActionConfirm, bool, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
if event.SourceEventID == "" || event.InvitationID <= 0 || event.InviterUserID <= 0 || event.TargetUserID <= 0 {
|
||||
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.InvalidArgument, "role invitation confirm event is incomplete")
|
||||
}
|
||||
subtype := strings.TrimSpace(event.InvitationType)
|
||||
if subtype == "" {
|
||||
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.InvalidArgument, "invitation_type is required")
|
||||
}
|
||||
nowMS := firstPositiveInt64(event.CreatedAtMS, s.now().UnixMilli())
|
||||
messageText := roleInvitationMessageText(event)
|
||||
metadataJSON := strings.TrimSpace(event.RawPayloadJSON)
|
||||
if metadataJSON == "" || !json.Valid([]byte(metadataJSON)) {
|
||||
metadataJSON = "{}"
|
||||
}
|
||||
confirmID := idgen.New("cfm")
|
||||
payloadJSON, err := confirmIMPayloadJSON(confirmID, event, messageText, nowMS)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
confirm := messagedomain.ActionConfirm{
|
||||
ConfirmID: confirmID,
|
||||
SourceName: messagedomain.ActionConfirmSourceUserOutbox,
|
||||
SourceEventID: event.SourceEventID,
|
||||
BusinessType: messagedomain.ActionConfirmBusinessRoleInvitation,
|
||||
BusinessSubtype: subtype,
|
||||
BusinessID: strconv.FormatInt(event.InvitationID, 10),
|
||||
SenderUserID: event.InviterUserID,
|
||||
ReceiverUserID: event.TargetUserID,
|
||||
MessageText: messageText,
|
||||
Status: messagedomain.ActionConfirmStatusPending,
|
||||
MetadataJSON: metadataJSON,
|
||||
CreatedAtMS: nowMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
}
|
||||
outbox := messagedomain.ActionConfirmOutbox{
|
||||
EventID: idgen.New("maout"),
|
||||
ConfirmID: confirm.ConfirmID,
|
||||
EventType: messagedomain.ActionConfirmEventCreated,
|
||||
Status: "pending",
|
||||
PayloadJSON: payloadJSON,
|
||||
CreatedAtMS: nowMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
}
|
||||
created, saved, err := s.repository.SaveActionConfirmWithOutbox(ctx, confirm, outbox)
|
||||
return saved, created, err
|
||||
}
|
||||
|
||||
// AcceptActionConfirm executes the accept branch for the current receiver.
|
||||
func (s *ActionConfirmService) AcceptActionConfirm(ctx context.Context, actorUserID int64, confirmID string, commandID string) (messagedomain.ActionConfirm, error) {
|
||||
return s.processAction(ctx, actorUserID, confirmID, commandID, messagedomain.ActionConfirmActionAccept, "")
|
||||
}
|
||||
|
||||
// RejectActionConfirm executes the reject branch for the current receiver.
|
||||
func (s *ActionConfirmService) RejectActionConfirm(ctx context.Context, actorUserID int64, confirmID string, commandID string, reason string) (messagedomain.ActionConfirm, error) {
|
||||
return s.processAction(ctx, actorUserID, confirmID, commandID, messagedomain.ActionConfirmActionReject, reason)
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) processAction(ctx context.Context, actorUserID int64, confirmID string, commandID string, action string, reason string) (messagedomain.ActionConfirm, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
if s.roleInvitationClient == nil {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.Unavailable, "role invitation client is not configured")
|
||||
}
|
||||
confirmID = strings.TrimSpace(confirmID)
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if actorUserID <= 0 || confirmID == "" || commandID == "" {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "actor_user_id, confirm_id and command_id are required")
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
claimed, ok, err := s.repository.ClaimActionConfirm(ctx, confirmID, actorUserID, action, commandID, s.workerID(), nowMS, nowMS+actionConfirmDefaultLockTTL.Milliseconds())
|
||||
if err != nil || !ok {
|
||||
return claimed, err
|
||||
}
|
||||
if isTerminalConfirmStatus(claimed.Status) {
|
||||
return claimed, nil
|
||||
}
|
||||
invitationID, err := strconv.ParseInt(claimed.BusinessID, 10, 64)
|
||||
if err != nil || invitationID <= 0 {
|
||||
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, "invalid business_id", s.now().UnixMilli())
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_id is invalid")
|
||||
}
|
||||
if claimed.BusinessType != messagedomain.ActionConfirmBusinessRoleInvitation {
|
||||
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, "unsupported business_type", s.now().UnixMilli())
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_type is unsupported")
|
||||
}
|
||||
state, err := s.roleInvitationClient.ProcessRoleInvitation(ctx, ProcessRoleInvitationInput{
|
||||
CommandID: commandID,
|
||||
ActorUserID: actorUserID,
|
||||
InvitationID: invitationID,
|
||||
Action: action,
|
||||
Reason: strings.TrimSpace(reason),
|
||||
})
|
||||
if err != nil {
|
||||
reconciled, reconcileErr := s.reconcileRoleInvitation(ctx, claimed, actorUserID, commandID, action)
|
||||
if reconcileErr == nil && isTerminalConfirmStatus(reconciled.Status) {
|
||||
return reconciled, nil
|
||||
}
|
||||
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, err.Error(), s.now().UnixMilli())
|
||||
return messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
return s.repository.CompleteActionConfirm(ctx, confirmID, commandID, confirmStatusFromRoleInvitation(state.Status), actionFromRoleInvitationStatus(state.Status, action), s.now().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) reconcileRoleInvitation(ctx context.Context, confirm messagedomain.ActionConfirm, actorUserID int64, commandID string, action string) (messagedomain.ActionConfirm, error) {
|
||||
invitationID, err := strconv.ParseInt(confirm.BusinessID, 10, 64)
|
||||
if err != nil || invitationID <= 0 {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_id is invalid")
|
||||
}
|
||||
state, err := s.roleInvitationClient.GetRoleInvitation(ctx, actorUserID, invitationID)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
status := confirmStatusFromRoleInvitation(state.Status)
|
||||
if !isTerminalConfirmStatus(status) {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.Conflict, "role invitation is not terminal")
|
||||
}
|
||||
return s.repository.CompleteActionConfirm(ctx, confirm.ConfirmID, commandID, status, actionFromRoleInvitationStatus(state.Status, action), s.now().UnixMilli())
|
||||
}
|
||||
|
||||
// BatchGetActionConfirmStatus returns only confirms visible to the current user.
|
||||
func (s *ActionConfirmService) BatchGetActionConfirmStatus(ctx context.Context, userID int64, confirmIDs []string) ([]messagedomain.ActionConfirm, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
normalized := make([]string, 0, len(confirmIDs))
|
||||
seen := make(map[string]struct{}, len(confirmIDs))
|
||||
for _, confirmID := range confirmIDs {
|
||||
confirmID = strings.TrimSpace(confirmID)
|
||||
if confirmID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[confirmID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[confirmID] = struct{}{}
|
||||
normalized = append(normalized, confirmID)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []messagedomain.ActionConfirm{}, nil
|
||||
}
|
||||
if len(normalized) > 100 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "confirm_ids is too large")
|
||||
}
|
||||
return s.repository.BatchGetActionConfirms(ctx, userID, normalized)
|
||||
}
|
||||
|
||||
// ListConversationActionConfirms returns confirm states for one C2C peer conversation.
|
||||
func (s *ActionConfirmService) ListConversationActionConfirms(ctx context.Context, userID int64, peerUserID int64, pageSize int32, pageToken string) ([]messagedomain.ActionConfirm, string, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if userID <= 0 || peerUserID <= 0 {
|
||||
return nil, "", xerr.New(xerr.InvalidArgument, "user_id and peer_user_id are required")
|
||||
}
|
||||
limit := int(pageSize)
|
||||
if limit <= 0 {
|
||||
limit = actionConfirmDefaultLimit
|
||||
}
|
||||
if limit > actionConfirmMaxLimit {
|
||||
limit = actionConfirmMaxLimit
|
||||
}
|
||||
cursor, err := decodeActionConfirmCursor(pageToken)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
items, err := s.repository.ListConversationActionConfirms(ctx, userID, peerUserID, limit+1, cursor.CreatedAtMS, cursor.ConfirmID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(items) <= limit {
|
||||
return items, "", nil
|
||||
}
|
||||
page := items[:limit]
|
||||
return page, encodeActionConfirmCursor(page[len(page)-1]), nil
|
||||
}
|
||||
|
||||
// ClaimPendingOutbox locks pending action-message outbox rows for the MQ publisher.
|
||||
func (s *ActionConfirmService) ClaimPendingOutbox(ctx context.Context, batchSize int, lockTTL time.Duration) ([]messagedomain.ActionConfirmOutbox, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = 30 * time.Second
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
return s.repository.ClaimPendingActionConfirmOutbox(ctx, s.workerID(), nowMS, nowMS+lockTTL.Milliseconds(), batchSize)
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) MarkOutboxDelivered(ctx context.Context, eventID string) error {
|
||||
return s.repository.MarkActionConfirmOutboxDelivered(ctx, eventID, s.now().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) MarkOutboxRetryable(ctx context.Context, eventID string, cause error, nextRetryAtMS int64) error {
|
||||
message := ""
|
||||
if cause != nil {
|
||||
message = cause.Error()
|
||||
}
|
||||
return s.repository.MarkActionConfirmOutboxRetryable(ctx, eventID, message, nextRetryAtMS, s.now().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) requireRepository() error {
|
||||
if s == nil || s.repository == nil {
|
||||
return xerr.New(xerr.Unavailable, "action confirm repository is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ActionConfirmService) workerID() string {
|
||||
nodeID := strings.TrimSpace(s.cfg.NodeID)
|
||||
if nodeID == "" {
|
||||
nodeID = "activity"
|
||||
}
|
||||
return "message-action-confirm-" + nodeID
|
||||
}
|
||||
|
||||
type confirmIMPayload struct {
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
ConfirmID string `json:"confirm_id"`
|
||||
BusinessType string `json:"business_type"`
|
||||
BusinessSubtype string `json:"business_subtype"`
|
||||
SenderUserID string `json:"sender_user_id"`
|
||||
ReceiverUserID string `json:"receiver_user_id"`
|
||||
Msg string `json:"msg"`
|
||||
Actions []confirmIMAction `json:"actions"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
type confirmIMAction struct {
|
||||
Action string `json:"action"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func confirmIMPayloadJSON(confirmID string, event messagedomain.RoleInvitationConfirmEvent, messageText string, createdAtMS int64) (string, error) {
|
||||
payload := confirmIMPayload{
|
||||
Type: "im_confirm",
|
||||
Version: 1,
|
||||
ConfirmID: confirmID,
|
||||
BusinessType: messagedomain.ActionConfirmBusinessRoleInvitation,
|
||||
BusinessSubtype: event.InvitationType,
|
||||
SenderUserID: strconv.FormatInt(event.InviterUserID, 10),
|
||||
ReceiverUserID: strconv.FormatInt(event.TargetUserID, 10),
|
||||
Msg: messageText,
|
||||
Actions: []confirmIMAction{
|
||||
{Action: messagedomain.ActionConfirmActionReject, Text: "Reject"},
|
||||
{Action: messagedomain.ActionConfirmActionAccept, Text: "Accept"},
|
||||
},
|
||||
CreatedAtMS: createdAtMS,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func roleInvitationMessageText(event messagedomain.RoleInvitationConfirmEvent) string {
|
||||
name := firstNonEmptyText(event.Inviter.Username, event.Inviter.DisplayUserID, strconv.FormatInt(event.InviterUserID, 10))
|
||||
agencyName := firstNonEmptyText(event.AgencyName, "guild")
|
||||
switch event.InvitationType {
|
||||
case "host":
|
||||
return fmt.Sprintf("%s has invited you to join the %s guild", name, agencyName)
|
||||
case "agency":
|
||||
return fmt.Sprintf("%s has invited you to create the %s guild", name, agencyName)
|
||||
case "bd":
|
||||
return fmt.Sprintf("%s has invited you to become BD", name)
|
||||
default:
|
||||
return fmt.Sprintf("%s has sent you a confirmation request", name)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmStatusFromRoleInvitation(status string) string {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "accepted":
|
||||
return messagedomain.ActionConfirmStatusAccepted
|
||||
case "rejected":
|
||||
return messagedomain.ActionConfirmStatusRejected
|
||||
case "cancelled":
|
||||
return messagedomain.ActionConfirmStatusCanceled
|
||||
case "expired":
|
||||
return messagedomain.ActionConfirmStatusExpired
|
||||
default:
|
||||
return messagedomain.ActionConfirmStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func actionFromRoleInvitationStatus(status string, fallback string) string {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "accepted":
|
||||
return messagedomain.ActionConfirmActionAccept
|
||||
case "rejected":
|
||||
return messagedomain.ActionConfirmActionReject
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminalConfirmStatus(status string) bool {
|
||||
switch status {
|
||||
case messagedomain.ActionConfirmStatusAccepted, messagedomain.ActionConfirmStatusRejected, messagedomain.ActionConfirmStatusExpired, messagedomain.ActionConfirmStatusCanceled, messagedomain.ActionConfirmStatusFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPositiveInt64(values ...int64) int64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type actionConfirmCursor struct {
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ConfirmID string `json:"confirm_id"`
|
||||
}
|
||||
|
||||
func encodeActionConfirmCursor(confirm messagedomain.ActionConfirm) string {
|
||||
raw, err := json.Marshal(actionConfirmCursor{CreatedAtMS: confirm.CreatedAtMS, ConfirmID: confirm.ConfirmID})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func decodeActionConfirmCursor(value string) (actionConfirmCursor, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return actionConfirmCursor{}, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
|
||||
}
|
||||
var cursor actionConfirmCursor
|
||||
if err := json.Unmarshal(raw, &cursor); err != nil {
|
||||
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
|
||||
}
|
||||
if cursor.CreatedAtMS <= 0 || strings.TrimSpace(cursor.ConfirmID) == "" {
|
||||
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
|
||||
}
|
||||
return cursor, nil
|
||||
}
|
||||
@ -0,0 +1,387 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
||||
)
|
||||
|
||||
// SaveActionConfirmWithOutbox creates the confirm row and IM outbox in one transaction.
|
||||
func (r *Repository) SaveActionConfirmWithOutbox(ctx context.Context, confirm messagedomain.ActionConfirm, outbox messagedomain.ActionConfirmOutbox) (bool, messagedomain.ActionConfirm, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, messagedomain.ActionConfirm{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
app := appcode.FromContext(ctx)
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
confirm.AppCode = app
|
||||
outbox.AppCode = app
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO message_action_confirms (
|
||||
app_code, confirm_id, source_name, source_event_id, business_type, business_subtype, business_id,
|
||||
sender_user_id, receiver_user_id, message_text, status, action, command_id, processed_at_ms,
|
||||
locked_by, lock_until_ms, error_message, metadata_json, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', 0, '', 0, '', CAST(? AS JSON), ?, ?)`,
|
||||
app, confirm.ConfirmID, confirm.SourceName, confirm.SourceEventID, confirm.BusinessType, confirm.BusinessSubtype, confirm.BusinessID,
|
||||
confirm.SenderUserID, confirm.ReceiverUserID, confirm.MessageText, confirm.Status, nullJSONText(confirm.MetadataJSON), confirm.CreatedAtMS, confirm.UpdatedAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
if isMySQLDuplicate(err) {
|
||||
existing, existingErr := getActionConfirmBySource(ctx, tx, confirm.SourceName, confirm.SourceEventID)
|
||||
return false, existing, existingErr
|
||||
}
|
||||
return false, messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO message_action_outbox (
|
||||
app_code, event_id, confirm_id, event_type, status, worker_id, lock_until_ms,
|
||||
retry_count, next_retry_at_ms, last_error, payload_json, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, '', 0, 0, 0, '', CAST(? AS JSON), ?, ?)`,
|
||||
app, outbox.EventID, confirm.ConfirmID, outbox.EventType, outbox.Status, outbox.PayloadJSON, outbox.CreatedAtMS, outbox.UpdatedAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
if isMySQLDuplicate(err) {
|
||||
existing, existingErr := getActionConfirmBySource(ctx, tx, confirm.SourceName, confirm.SourceEventID)
|
||||
return false, existing, existingErr
|
||||
}
|
||||
return false, messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
return true, confirm, nil
|
||||
}
|
||||
|
||||
// ClaimActionConfirm moves a pending or expired-processing confirm into processing under row lock.
|
||||
func (r *Repository) ClaimActionConfirm(ctx context.Context, confirmID string, actorUserID int64, action string, commandID string, workerID string, nowMS int64, lockUntilMS int64) (messagedomain.ActionConfirm, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
confirm, err := getActionConfirmByIDForUpdate(ctx, tx, confirmID)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
if confirm.ReceiverUserID != actorUserID {
|
||||
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.PermissionDenied, "action confirm is not visible to actor")
|
||||
}
|
||||
if isActionConfirmTerminal(confirm.Status) {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
return confirm, false, nil
|
||||
}
|
||||
if confirm.Status == messagedomain.ActionConfirmStatusProcessing && confirm.LockUntilMS > nowMS {
|
||||
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.Conflict, "action confirm is processing")
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE message_action_confirms
|
||||
SET status = ?, action = '', command_id = ?, locked_by = ?, lock_until_ms = ?, error_message = '', updated_at_ms = ?
|
||||
WHERE app_code = ? AND confirm_id = ?`,
|
||||
messagedomain.ActionConfirmStatusProcessing, commandID, workerID, lockUntilMS, nowMS, appcode.FromContext(ctx), confirmID,
|
||||
)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return messagedomain.ActionConfirm{}, false, err
|
||||
}
|
||||
confirm.Status = messagedomain.ActionConfirmStatusProcessing
|
||||
confirm.CommandID = commandID
|
||||
confirm.LockedBy = workerID
|
||||
confirm.LockUntilMS = lockUntilMS
|
||||
confirm.UpdatedAtMS = nowMS
|
||||
return confirm, true, nil
|
||||
}
|
||||
|
||||
// CompleteActionConfirm writes the terminal state after the owner service accepts the business command.
|
||||
func (r *Repository) CompleteActionConfirm(ctx context.Context, confirmID string, commandID string, status string, action string, processedAtMS int64) (messagedomain.ActionConfirm, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE message_action_confirms
|
||||
SET status = ?, action = ?, processed_at_ms = ?, locked_by = '', lock_until_ms = 0, error_message = '', updated_at_ms = ?
|
||||
WHERE app_code = ? AND confirm_id = ? AND command_id = ?`,
|
||||
status, action, processedAtMS, processedAtMS, appcode.FromContext(ctx), confirmID, commandID,
|
||||
)
|
||||
if err != nil {
|
||||
return messagedomain.ActionConfirm{}, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return messagedomain.ActionConfirm{}, err
|
||||
} else if affected == 0 {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.Conflict, "action confirm processing lock is lost")
|
||||
}
|
||||
return r.getActionConfirmByID(ctx, confirmID)
|
||||
}
|
||||
|
||||
// ReleaseActionConfirm returns a failed processing attempt to pending so the user can retry with a new command.
|
||||
func (r *Repository) ReleaseActionConfirm(ctx context.Context, confirmID string, commandID string, errorMessage string, nowMS int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE message_action_confirms
|
||||
SET status = ?, locked_by = '', lock_until_ms = 0, error_message = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND confirm_id = ? AND command_id = ? AND status = ?`,
|
||||
messagedomain.ActionConfirmStatusPending, truncateMessageActionError(errorMessage), nowMS, appcode.FromContext(ctx), confirmID, commandID, messagedomain.ActionConfirmStatusProcessing,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// BatchGetActionConfirms returns current-user-visible confirm rows for a bounded ID list.
|
||||
func (r *Repository) BatchGetActionConfirms(ctx context.Context, userID int64, confirmIDs []string) ([]messagedomain.ActionConfirm, error) {
|
||||
if len(confirmIDs) == 0 {
|
||||
return []messagedomain.ActionConfirm{}, nil
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(confirmIDs)), ",")
|
||||
args := []any{appcode.FromContext(ctx), userID}
|
||||
for _, confirmID := range confirmIDs {
|
||||
args = append(args, confirmID)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM message_action_confirms
|
||||
WHERE app_code = ? AND receiver_user_id = ? AND confirm_id IN (`+placeholders+`)
|
||||
ORDER BY created_at_ms DESC, confirm_id DESC`, actionConfirmColumns), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanActionConfirmRows(rows)
|
||||
}
|
||||
|
||||
// ListConversationActionConfirms reads one cursor page for confirms sent by a specific peer.
|
||||
func (r *Repository) ListConversationActionConfirms(ctx context.Context, userID int64, peerUserID int64, limit int, cursorCreatedAtMS int64, cursorConfirmID string) ([]messagedomain.ActionConfirm, error) {
|
||||
args := []any{appcode.FromContext(ctx), userID, peerUserID}
|
||||
cursorSQL := ""
|
||||
if cursorCreatedAtMS > 0 || cursorConfirmID != "" {
|
||||
cursorSQL = " AND (created_at_ms < ? OR (created_at_ms = ? AND confirm_id < ?))"
|
||||
args = append(args, cursorCreatedAtMS, cursorCreatedAtMS, cursorConfirmID)
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM message_action_confirms
|
||||
WHERE app_code = ? AND receiver_user_id = ? AND sender_user_id = ?`+cursorSQL+`
|
||||
ORDER BY created_at_ms DESC, confirm_id DESC
|
||||
LIMIT ?`, actionConfirmColumns), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanActionConfirmRows(rows)
|
||||
}
|
||||
|
||||
// ClaimPendingActionConfirmOutbox locks pending/retryable confirm IM facts for RocketMQ publishing.
|
||||
func (r *Repository) ClaimPendingActionConfirmOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, batchSize int) ([]messagedomain.ActionConfirmOutbox, error) {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
records := make([]messagedomain.ActionConfirmOutbox, 0, batchSize)
|
||||
branches := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{query: actionOutboxClaimQuery("status = 'pending' AND next_retry_at_ms <= ?"), args: []any{appcode.FromContext(ctx), nowMS}},
|
||||
{query: actionOutboxClaimQuery("status = 'retryable' AND next_retry_at_ms <= ?"), args: []any{appcode.FromContext(ctx), nowMS}},
|
||||
{query: actionOutboxClaimQuery("status = 'running' AND lock_until_ms <= ?"), args: []any{appcode.FromContext(ctx), nowMS}},
|
||||
}
|
||||
for _, branch := range branches {
|
||||
remaining := batchSize - len(records)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
args := append(append([]any{}, branch.args...), remaining)
|
||||
items, err := queryActionConfirmOutboxRows(ctx, tx, branch.query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, items...)
|
||||
}
|
||||
for _, record := range records {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE message_action_outbox
|
||||
SET status = 'running', worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
workerID, lockUntilMS, nowMS, record.AppCode, record.EventID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range records {
|
||||
records[index].Status = "running"
|
||||
records[index].WorkerID = workerID
|
||||
records[index].LockUntilMS = lockUntilMS
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// MarkActionConfirmOutboxDelivered records that RocketMQ accepted the confirm IM fact.
|
||||
func (r *Repository) MarkActionConfirmOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE message_action_outbox
|
||||
SET status = 'delivered', worker_id = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
nowMS, appcode.FromContext(ctx), eventID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkActionConfirmOutboxRetryable releases a failed publish with a bounded retry delay.
|
||||
func (r *Repository) MarkActionConfirmOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64, nowMS int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE message_action_outbox
|
||||
SET status = 'retryable', worker_id = '', lock_until_ms = 0, retry_count = retry_count + 1,
|
||||
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND event_id = ? AND status = 'running'`,
|
||||
nextRetryAtMS, truncateMessageActionError(lastErr), nowMS, appcode.FromContext(ctx), eventID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const actionConfirmColumns = `
|
||||
app_code, confirm_id, source_name, source_event_id, business_type, business_subtype, business_id,
|
||||
sender_user_id, receiver_user_id, message_text, status, action, command_id, processed_at_ms,
|
||||
locked_by, lock_until_ms, error_message, COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms`
|
||||
|
||||
func (r *Repository) getActionConfirmByID(ctx context.Context, confirmID string) (messagedomain.ActionConfirm, error) {
|
||||
return scanActionConfirm(r.db.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM message_action_confirms WHERE app_code = ? AND confirm_id = ?`,
|
||||
actionConfirmColumns), appcode.FromContext(ctx), confirmID))
|
||||
}
|
||||
|
||||
func getActionConfirmByIDForUpdate(ctx context.Context, tx *sql.Tx, confirmID string) (messagedomain.ActionConfirm, error) {
|
||||
return scanActionConfirm(tx.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM message_action_confirms WHERE app_code = ? AND confirm_id = ? FOR UPDATE`,
|
||||
actionConfirmColumns), appcode.FromContext(ctx), confirmID))
|
||||
}
|
||||
|
||||
func getActionConfirmBySource(ctx context.Context, tx *sql.Tx, sourceName string, sourceEventID string) (messagedomain.ActionConfirm, error) {
|
||||
return scanActionConfirm(tx.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM message_action_confirms WHERE app_code = ? AND source_name = ? AND source_event_id = ?`,
|
||||
actionConfirmColumns), appcode.FromContext(ctx), sourceName, sourceEventID))
|
||||
}
|
||||
|
||||
func scanActionConfirm(scanner inboxScanner) (messagedomain.ActionConfirm, error) {
|
||||
var confirm messagedomain.ActionConfirm
|
||||
err := scanner.Scan(
|
||||
&confirm.AppCode,
|
||||
&confirm.ConfirmID,
|
||||
&confirm.SourceName,
|
||||
&confirm.SourceEventID,
|
||||
&confirm.BusinessType,
|
||||
&confirm.BusinessSubtype,
|
||||
&confirm.BusinessID,
|
||||
&confirm.SenderUserID,
|
||||
&confirm.ReceiverUserID,
|
||||
&confirm.MessageText,
|
||||
&confirm.Status,
|
||||
&confirm.Action,
|
||||
&confirm.CommandID,
|
||||
&confirm.ProcessedAtMS,
|
||||
&confirm.LockedBy,
|
||||
&confirm.LockUntilMS,
|
||||
&confirm.ErrorMessage,
|
||||
&confirm.MetadataJSON,
|
||||
&confirm.CreatedAtMS,
|
||||
&confirm.UpdatedAtMS,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return messagedomain.ActionConfirm{}, xerr.New(xerr.NotFound, "action confirm not found")
|
||||
}
|
||||
return confirm, err
|
||||
}
|
||||
|
||||
func scanActionConfirmRows(rows *sql.Rows) ([]messagedomain.ActionConfirm, error) {
|
||||
items := make([]messagedomain.ActionConfirm, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanActionConfirm(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func actionOutboxClaimQuery(condition string) string {
|
||||
return `
|
||||
SELECT app_code, event_id, confirm_id, event_type, status, worker_id, lock_until_ms,
|
||||
retry_count, next_retry_at_ms, last_error, CAST(payload_json AS CHAR), created_at_ms, updated_at_ms
|
||||
FROM message_action_outbox
|
||||
WHERE app_code = ? AND ` + condition + `
|
||||
ORDER BY created_at_ms ASC, event_id ASC
|
||||
LIMIT ? FOR UPDATE SKIP LOCKED`
|
||||
}
|
||||
|
||||
func queryActionConfirmOutboxRows(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]messagedomain.ActionConfirmOutbox, error) {
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
records := make([]messagedomain.ActionConfirmOutbox, 0)
|
||||
for rows.Next() {
|
||||
var record messagedomain.ActionConfirmOutbox
|
||||
if err := rows.Scan(
|
||||
&record.AppCode,
|
||||
&record.EventID,
|
||||
&record.ConfirmID,
|
||||
&record.EventType,
|
||||
&record.Status,
|
||||
&record.WorkerID,
|
||||
&record.LockUntilMS,
|
||||
&record.RetryCount,
|
||||
&record.NextRetryAtMS,
|
||||
&record.LastError,
|
||||
&record.PayloadJSON,
|
||||
&record.CreatedAtMS,
|
||||
&record.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func isActionConfirmTerminal(status string) bool {
|
||||
switch status {
|
||||
case messagedomain.ActionConfirmStatusAccepted, messagedomain.ActionConfirmStatusRejected, messagedomain.ActionConfirmStatusExpired, messagedomain.ActionConfirmStatusCanceled, messagedomain.ActionConfirmStatusFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func nullJSONText(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "{}"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func truncateMessageActionError(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) <= 512 {
|
||||
return message
|
||||
}
|
||||
return message[:512]
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ActionConfirmServer adapts generic IM action confirmations to gRPC.
|
||||
type ActionConfirmServer struct {
|
||||
activityv1.UnimplementedMessageActionConfirmServiceServer
|
||||
|
||||
svc *messageservice.ActionConfirmService
|
||||
}
|
||||
|
||||
func NewActionConfirmServer(svc *messageservice.ActionConfirmService) *ActionConfirmServer {
|
||||
return &ActionConfirmServer{svc: svc}
|
||||
}
|
||||
|
||||
func (s *ActionConfirmServer) AcceptActionConfirm(ctx context.Context, req *activityv1.AcceptActionConfirmRequest) (*activityv1.AcceptActionConfirmResponse, error) {
|
||||
if s.svc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "action confirm service is not configured"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
confirm, err := s.svc.AcceptActionConfirm(ctx, req.GetActorUserId(), req.GetConfirmId(), req.GetCommandId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.AcceptActionConfirmResponse{Confirm: actionConfirmProto(confirm)}, nil
|
||||
}
|
||||
|
||||
func (s *ActionConfirmServer) RejectActionConfirm(ctx context.Context, req *activityv1.RejectActionConfirmRequest) (*activityv1.RejectActionConfirmResponse, error) {
|
||||
if s.svc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "action confirm service is not configured"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
confirm, err := s.svc.RejectActionConfirm(ctx, req.GetActorUserId(), req.GetConfirmId(), req.GetCommandId(), req.GetReason())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.RejectActionConfirmResponse{Confirm: actionConfirmProto(confirm)}, nil
|
||||
}
|
||||
|
||||
func (s *ActionConfirmServer) BatchGetActionConfirmStatus(ctx context.Context, req *activityv1.BatchGetActionConfirmStatusRequest) (*activityv1.BatchGetActionConfirmStatusResponse, error) {
|
||||
if s.svc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "action confirm service is not configured"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
items, err := s.svc.BatchGetActionConfirmStatus(ctx, req.GetUserId(), req.GetConfirmIds())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &activityv1.BatchGetActionConfirmStatusResponse{Items: make([]*activityv1.ActionConfirm, 0, len(items))}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, actionConfirmProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *ActionConfirmServer) ListConversationActionConfirms(ctx context.Context, req *activityv1.ListConversationActionConfirmsRequest) (*activityv1.ListConversationActionConfirmsResponse, error) {
|
||||
if s.svc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "action confirm service is not configured"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
items, nextPageToken, err := s.svc.ListConversationActionConfirms(ctx, req.GetUserId(), req.GetPeerUserId(), req.GetPageSize(), req.GetPageToken())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &activityv1.ListConversationActionConfirmsResponse{Items: make([]*activityv1.ActionConfirm, 0, len(items)), NextPageToken: nextPageToken}
|
||||
for _, item := range items {
|
||||
resp.Items = append(resp.Items, actionConfirmProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func actionConfirmProto(confirm messagedomain.ActionConfirm) *activityv1.ActionConfirm {
|
||||
return &activityv1.ActionConfirm{
|
||||
ConfirmId: confirm.ConfirmID,
|
||||
BusinessType: confirm.BusinessType,
|
||||
BusinessSubtype: confirm.BusinessSubtype,
|
||||
BusinessId: confirm.BusinessID,
|
||||
SenderUserId: confirm.SenderUserID,
|
||||
ReceiverUserId: confirm.ReceiverUserID,
|
||||
Status: confirm.Status,
|
||||
Action: confirm.Action,
|
||||
ProcessedAtMs: confirm.ProcessedAtMS,
|
||||
CreatedAtMs: confirm.CreatedAtMS,
|
||||
UpdatedAtMs: confirm.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
@ -98,6 +98,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
|
||||
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
|
||||
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
|
||||
var messageActionClient client.MessageActionConfirmClient = client.NewGRPCMessageActionConfirmClient(activityConn)
|
||||
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
|
||||
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
|
||||
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
||||
@ -173,6 +174,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
handler.SetAppRegistryClient(appRegistryClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
handler.SetMessageInboxClient(messageClient)
|
||||
handler.SetMessageActionConfirmClient(messageActionClient)
|
||||
handler.SetTaskClient(taskClient)
|
||||
handler.SetGrowthLevelClient(growthLevelClient)
|
||||
handler.SetAchievementClient(achievementClient)
|
||||
|
||||
@ -16,15 +16,32 @@ type MessageInboxClient interface {
|
||||
DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error)
|
||||
}
|
||||
|
||||
// MessageActionConfirmClient abstracts gateway access to generic IM action confirmations.
|
||||
type MessageActionConfirmClient interface {
|
||||
AcceptActionConfirm(ctx context.Context, req *activityv1.AcceptActionConfirmRequest) (*activityv1.AcceptActionConfirmResponse, error)
|
||||
RejectActionConfirm(ctx context.Context, req *activityv1.RejectActionConfirmRequest) (*activityv1.RejectActionConfirmResponse, error)
|
||||
BatchGetActionConfirmStatus(ctx context.Context, req *activityv1.BatchGetActionConfirmStatusRequest) (*activityv1.BatchGetActionConfirmStatusResponse, error)
|
||||
ListConversationActionConfirms(ctx context.Context, req *activityv1.ListConversationActionConfirmsRequest) (*activityv1.ListConversationActionConfirmsResponse, error)
|
||||
}
|
||||
|
||||
type grpcMessageInboxClient struct {
|
||||
client activityv1.MessageInboxServiceClient
|
||||
}
|
||||
|
||||
type grpcMessageActionConfirmClient struct {
|
||||
client activityv1.MessageActionConfirmServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCMessageInboxClient builds an inbox client backed by activity-service MessageInboxService.
|
||||
func NewGRPCMessageInboxClient(conn *grpc.ClientConn) MessageInboxClient {
|
||||
return &grpcMessageInboxClient{client: activityv1.NewMessageInboxServiceClient(conn)}
|
||||
}
|
||||
|
||||
// NewGRPCMessageActionConfirmClient builds an action-confirm client backed by activity-service.
|
||||
func NewGRPCMessageActionConfirmClient(conn *grpc.ClientConn) MessageActionConfirmClient {
|
||||
return &grpcMessageActionConfirmClient{client: activityv1.NewMessageActionConfirmServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *grpcMessageInboxClient) ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
|
||||
return c.client.ListMessageTabs(ctx, req)
|
||||
}
|
||||
@ -44,3 +61,19 @@ func (c *grpcMessageInboxClient) MarkInboxSectionRead(ctx context.Context, req *
|
||||
func (c *grpcMessageInboxClient) DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
|
||||
return c.client.DeleteInboxMessage(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcMessageActionConfirmClient) AcceptActionConfirm(ctx context.Context, req *activityv1.AcceptActionConfirmRequest) (*activityv1.AcceptActionConfirmResponse, error) {
|
||||
return c.client.AcceptActionConfirm(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcMessageActionConfirmClient) RejectActionConfirm(ctx context.Context, req *activityv1.RejectActionConfirmRequest) (*activityv1.RejectActionConfirmResponse, error) {
|
||||
return c.client.RejectActionConfirm(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcMessageActionConfirmClient) BatchGetActionConfirmStatus(ctx context.Context, req *activityv1.BatchGetActionConfirmStatusRequest) (*activityv1.BatchGetActionConfirmStatusResponse, error) {
|
||||
return c.client.BatchGetActionConfirmStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcMessageActionConfirmClient) ListConversationActionConfirms(ctx context.Context, req *activityv1.ListConversationActionConfirmsRequest) (*activityv1.ListConversationActionConfirmsResponse, error) {
|
||||
return c.client.ListConversationActionConfirms(ctx, req)
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ type Handler struct {
|
||||
appRegistryClient client.AppRegistryClient
|
||||
walletClient client.WalletClient
|
||||
messageClient client.MessageInboxClient
|
||||
messageActionClient client.MessageActionConfirmClient
|
||||
taskClient client.TaskClient
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
achievementClient client.AchievementClient
|
||||
@ -213,6 +214,11 @@ func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient)
|
||||
h.messageClient = messageClient
|
||||
}
|
||||
|
||||
// SetMessageActionConfirmClient 注入 activity-service action-confirm client。
|
||||
func (h *Handler) SetMessageActionConfirmClient(messageActionClient client.MessageActionConfirmClient) {
|
||||
h.messageActionClient = messageActionClient
|
||||
}
|
||||
|
||||
// SetTaskClient 注入 activity-service task client。
|
||||
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
|
||||
h.taskClient = taskClient
|
||||
|
||||
@ -184,6 +184,10 @@ type MessageHandlers struct {
|
||||
MarkInboxMessageReadPath http.HandlerFunc
|
||||
DeleteInboxMessagePath http.HandlerFunc
|
||||
ListInboxMessages http.HandlerFunc
|
||||
AcceptActionConfirm http.HandlerFunc
|
||||
RejectActionConfirm http.HandlerFunc
|
||||
BatchActionConfirmStatus http.HandlerFunc
|
||||
ListActionConfirms http.HandlerFunc
|
||||
}
|
||||
|
||||
type TaskHandlers struct {
|
||||
@ -517,6 +521,10 @@ func (r routes) registerMessageRoutes() {
|
||||
r.profile("/messages/read-all", "", h.MarkInboxSectionRead)
|
||||
r.profile("/messages/{message_id}/read", "", h.MarkInboxMessageReadPath)
|
||||
r.profile("/messages/{message_id}", "", h.DeleteInboxMessagePath)
|
||||
r.profile("/message/action-confirms/{confirm_id}/accept", http.MethodPost, h.AcceptActionConfirm)
|
||||
r.profile("/message/action-confirms/{confirm_id}/reject", http.MethodPost, h.RejectActionConfirm)
|
||||
r.profile("/message/action-confirms/status", http.MethodPost, h.BatchActionConfirmStatus)
|
||||
r.profile("/message/action-confirms", http.MethodGet, h.ListActionConfirms)
|
||||
r.profile("/messages", "", h.ListInboxMessages)
|
||||
}
|
||||
|
||||
|
||||
@ -11,17 +11,20 @@ import (
|
||||
// Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts.
|
||||
type Handler struct {
|
||||
messageClient client.MessageInboxClient
|
||||
actionClient client.MessageActionConfirmClient
|
||||
userProfileClient client.UserProfileClient
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
MessageClient client.MessageInboxClient
|
||||
ActionClient client.MessageActionConfirmClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
return &Handler{
|
||||
messageClient: config.MessageClient,
|
||||
actionClient: config.ActionClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
}
|
||||
}
|
||||
@ -33,6 +36,10 @@ func (h *Handler) MessageHandlers() httproutes.MessageHandlers {
|
||||
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
|
||||
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
|
||||
ListInboxMessages: h.listInboxMessages,
|
||||
AcceptActionConfirm: h.acceptActionConfirm,
|
||||
RejectActionConfirm: h.rejectActionConfirm,
|
||||
BatchActionConfirmStatus: h.batchActionConfirmStatus,
|
||||
ListActionConfirms: h.listActionConfirms,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,20 @@ type inboxMessageData struct {
|
||||
SentAtMS int64 `json:"sent_at_ms"`
|
||||
}
|
||||
|
||||
type actionConfirmData struct {
|
||||
ConfirmID string `json:"confirm_id"`
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action,omitempty"`
|
||||
BusinessType string `json:"business_type,omitempty"`
|
||||
BusinessSubtype string `json:"business_subtype,omitempty"`
|
||||
BusinessID string `json:"business_id,omitempty"`
|
||||
SenderUserID string `json:"sender_user_id,omitempty"`
|
||||
ReceiverUserID string `json:"receiver_user_id,omitempty"`
|
||||
ProcessedAtMS int64 `json:"processed_at_ms,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type userProfileBatchData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
@ -195,6 +209,149 @@ func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.R
|
||||
httpkit.WriteOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "deleted": resp.GetDeleted()})
|
||||
}
|
||||
|
||||
// acceptActionConfirm handles an IM confirm accept click; only confirm_id and command_id come from App.
|
||||
func (h *Handler) acceptActionConfirm(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.actionClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
confirmID := strings.TrimSpace(request.PathValue("confirm_id"))
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
}
|
||||
if confirmID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
resp, err := h.actionClient.AcceptActionConfirm(request.Context(), &activityv1.AcceptActionConfirmRequest{
|
||||
Meta: httpkit.ActivityMeta(request),
|
||||
ConfirmId: confirmID,
|
||||
ActorUserId: auth.UserIDFromContext(request.Context()),
|
||||
CommandId: body.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, actionConfirmResponseData(resp.GetConfirm()))
|
||||
}
|
||||
|
||||
// rejectActionConfirm handles an IM confirm reject click with an optional reason.
|
||||
func (h *Handler) rejectActionConfirm(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.actionClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
confirmID := strings.TrimSpace(request.PathValue("confirm_id"))
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if confirmID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
resp, err := h.actionClient.RejectActionConfirm(request.Context(), &activityv1.RejectActionConfirmRequest{
|
||||
Meta: httpkit.ActivityMeta(request),
|
||||
ConfirmId: confirmID,
|
||||
ActorUserId: auth.UserIDFromContext(request.Context()),
|
||||
CommandId: body.CommandID,
|
||||
Reason: body.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, actionConfirmResponseData(resp.GetConfirm()))
|
||||
}
|
||||
|
||||
// batchActionConfirmStatus returns confirm status for current user's visible confirm IDs.
|
||||
func (h *Handler) batchActionConfirmStatus(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.actionClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ConfirmIDs []string `json:"confirm_ids"`
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
resp, err := h.actionClient.BatchGetActionConfirmStatus(request.Context(), &activityv1.BatchGetActionConfirmStatusRequest{
|
||||
Meta: httpkit.ActivityMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
ConfirmIds: body.ConfirmIDs,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]actionConfirmData, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
items = append(items, actionConfirmResponseData(item))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// listActionConfirms returns confirm states for a C2C peer conversation.
|
||||
func (h *Handler) listActionConfirms(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.actionClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
peerUserID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("peer_user_id")), 10, 64)
|
||||
if err != nil || peerUserID <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
pageSize, ok := parseMessagePageSize(request.URL.Query().Get("page_size"))
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.actionClient.ListConversationActionConfirms(request.Context(), &activityv1.ListConversationActionConfirmsRequest{
|
||||
Meta: httpkit.ActivityMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
PeerUserId: peerUserID,
|
||||
PageSize: pageSize,
|
||||
PageToken: strings.TrimSpace(request.URL.Query().Get("page_token")),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]actionConfirmData, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
items = append(items, actionConfirmResponseData(item))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "next_page_token": resp.GetNextPageToken()})
|
||||
}
|
||||
|
||||
func actionConfirmResponseData(confirm *activityv1.ActionConfirm) actionConfirmData {
|
||||
if confirm == nil {
|
||||
return actionConfirmData{}
|
||||
}
|
||||
return actionConfirmData{
|
||||
ConfirmID: confirm.GetConfirmId(),
|
||||
Status: confirm.GetStatus(),
|
||||
Action: confirm.GetAction(),
|
||||
BusinessType: confirm.GetBusinessType(),
|
||||
BusinessSubtype: confirm.GetBusinessSubtype(),
|
||||
BusinessID: confirm.GetBusinessId(),
|
||||
SenderUserID: strconv.FormatInt(confirm.GetSenderUserId(), 10),
|
||||
ReceiverUserID: strconv.FormatInt(confirm.GetReceiverUserId(), 10),
|
||||
ProcessedAtMS: confirm.GetProcessedAtMs(),
|
||||
CreatedAtMS: confirm.GetCreatedAtMs(),
|
||||
UpdatedAtMS: confirm.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@ -68,6 +68,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
})
|
||||
messageAPI := messageapi.New(messageapi.Config{
|
||||
MessageClient: h.messageClient,
|
||||
ActionClient: h.messageActionClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
})
|
||||
activityAPI := activityapi.New(activityapi.Config{
|
||||
|
||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
confirm_notice_worker:
|
||||
enabled: false
|
||||
poll_interval: 1s
|
||||
batch_size: 100
|
||||
lock_ttl: 30s
|
||||
publish_timeout: 3s
|
||||
max_retry_count: 10
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
# Docker 本地真实链路消费 owner outbox MQ fanout,投递位点仍写 notice_delivery_events。
|
||||
enabled: true
|
||||
@ -71,6 +81,11 @@ rocketmq:
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
consumer_group: "hyapp-notice-message-action-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
confirm_notice_worker:
|
||||
enabled: false
|
||||
poll_interval: 1s
|
||||
batch_size: 100
|
||||
lock_ttl: 30s
|
||||
publish_timeout: 3s
|
||||
max_retry_count: 10
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
enabled: true
|
||||
name_servers:
|
||||
@ -71,6 +81,11 @@ rocketmq:
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
consumer_group: "hyapp-notice-message-action-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
confirm_notice_worker:
|
||||
enabled: false
|
||||
poll_interval: 1s
|
||||
batch_size: 100
|
||||
lock_ttl: 30s
|
||||
publish_timeout: 3s
|
||||
max_retry_count: 10
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
# notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
||||
enabled: true
|
||||
@ -71,6 +81,11 @@ rocketmq:
|
||||
topic: "hyapp_user_outbox"
|
||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
message_action_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_message_action_outbox"
|
||||
consumer_group: "hyapp-notice-message-action-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/healthhttp"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/messagemq"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/roommq"
|
||||
serviceapp "hyapp/pkg/servicekit/app"
|
||||
@ -23,6 +24,7 @@ import (
|
||||
"hyapp/pkg/usermq"
|
||||
"hyapp/pkg/walletmq"
|
||||
"hyapp/services/notice-service/internal/config"
|
||||
"hyapp/services/notice-service/internal/modules/confirmnotice"
|
||||
"hyapp/services/notice-service/internal/modules/cpnotice"
|
||||
"hyapp/services/notice-service/internal/modules/roomnotice"
|
||||
"hyapp/services/notice-service/internal/modules/walletnotice"
|
||||
@ -31,22 +33,24 @@ import (
|
||||
|
||||
// App owns the notice-service process lifecycle.
|
||||
type App struct {
|
||||
server *grpc.Server
|
||||
listener net.Listener
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
store *mysqlplatform.Store
|
||||
walletNoticeService *walletnotice.Service
|
||||
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
|
||||
walletWorkerEnabled bool
|
||||
roomNoticeService *roomnotice.Service
|
||||
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
|
||||
roomWorkerEnabled bool
|
||||
cpNoticeService *cpnotice.Service
|
||||
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
workers *serviceapp.BackgroundGroup
|
||||
closeOnce sync.Once
|
||||
server *grpc.Server
|
||||
listener net.Listener
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
store *mysqlplatform.Store
|
||||
walletNoticeService *walletnotice.Service
|
||||
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
|
||||
walletWorkerEnabled bool
|
||||
roomNoticeService *roomnotice.Service
|
||||
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
|
||||
roomWorkerEnabled bool
|
||||
cpNoticeService *cpnotice.Service
|
||||
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
|
||||
confirmNoticeService *confirmnotice.Service
|
||||
confirmWorkerOptions confirmnotice.WorkerOptions
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
workers *serviceapp.BackgroundGroup
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// New initializes MySQL, module repositories and optional Tencent IM publisher.
|
||||
@ -77,6 +81,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
cpRepo := cpnotice.NewMySQLRepository(store.DB)
|
||||
confirmRepo := confirmnotice.NewMySQLRepository(store.DB)
|
||||
var publisher *tencentim.RESTClient
|
||||
if cfg.TencentIM.Enabled {
|
||||
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||
@ -112,6 +117,11 @@ func New(cfg config.Config) (*App, error) {
|
||||
_ = store.Close()
|
||||
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
|
||||
}
|
||||
if cfg.RocketMQ.MessageActionOutbox.Enabled && publisher == nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
return nil, errors.New("message action outbox mq consumer requires tencent_im.enabled")
|
||||
}
|
||||
|
||||
server := servicegrpc.New("notice-service")
|
||||
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
||||
@ -134,7 +144,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
||||
cpSvc := cpnotice.New(cpnotice.Config{NodeID: cfg.NodeID, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix}, cpRepo, publisher)
|
||||
cpOptions := cpNoticeWorkerOptions(cfg.CPNoticeWorker)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 3)
|
||||
confirmSvc := confirmnotice.New(confirmnotice.Config{NodeID: cfg.NodeID}, confirmRepo, publisher)
|
||||
confirmOptions := confirmNoticeWorkerOptions(cfg.ConfirmNoticeWorker)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
|
||||
if cfg.RocketMQ.WalletOutbox.Enabled {
|
||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
@ -203,22 +215,47 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
if cfg.RocketMQ.MessageActionOutbox.Enabled {
|
||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.MessageActionOutbox.ConsumerGroup, cfg.RocketMQ.MessageActionOutbox.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
shutdownConsumers(mqConsumers)
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.MessageActionOutbox.Topic, messagemq.TagMessageActionOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
actionMessage, err := messagemq.DecodeActionOutboxMessage(message.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = confirmSvc.ProcessActionOutboxMessage(appcode.WithContext(ctx, actionMessage.AppCode), actionMessage, confirmOptions)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
shutdownConsumers(mqConsumers)
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
return &App{
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
store: store,
|
||||
walletNoticeService: walletSvc,
|
||||
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
|
||||
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
|
||||
roomNoticeService: roomSvc,
|
||||
roomWorkerOptions: roomOptions,
|
||||
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
|
||||
cpNoticeService: cpSvc,
|
||||
cpWorkerOptions: cpOptions,
|
||||
mqConsumers: mqConsumers,
|
||||
workers: serviceapp.NewBackground(context.Background()),
|
||||
server: server,
|
||||
listener: listener,
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
store: store,
|
||||
walletNoticeService: walletSvc,
|
||||
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
|
||||
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
|
||||
roomNoticeService: roomSvc,
|
||||
roomWorkerOptions: roomOptions,
|
||||
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
|
||||
cpNoticeService: cpSvc,
|
||||
cpWorkerOptions: cpOptions,
|
||||
confirmNoticeService: confirmSvc,
|
||||
confirmWorkerOptions: confirmOptions,
|
||||
mqConsumers: mqConsumers,
|
||||
workers: serviceapp.NewBackground(context.Background()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -325,6 +362,17 @@ func cpNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) cpnotice.CPNotic
|
||||
}
|
||||
}
|
||||
|
||||
func confirmNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) confirmnotice.WorkerOptions {
|
||||
return confirmnotice.WorkerOptions{
|
||||
BatchSize: cfg.BatchSize,
|
||||
LockTTL: cfg.LockTTL,
|
||||
PublishTimeout: cfg.PublishTimeout,
|
||||
MaxRetryCount: cfg.MaxRetryCount,
|
||||
InitialBackoff: cfg.InitialBackoff,
|
||||
MaxBackoff: cfg.MaxBackoff,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startMQ() error {
|
||||
return servicemq.StartConsumers(a.mqConsumers)
|
||||
}
|
||||
|
||||
@ -33,6 +33,8 @@ type Config struct {
|
||||
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
||||
// CPNoticeWorker 控制 CP user_outbox 事实转腾讯 IM 的投递锁和重试策略。
|
||||
CPNoticeWorker WalletNoticeWorkerConfig `yaml:"cp_notice_worker"`
|
||||
// ConfirmNoticeWorker 控制通用确认消息转腾讯 IM 的投递锁和重试策略。
|
||||
ConfirmNoticeWorker WalletNoticeWorkerConfig `yaml:"confirm_notice_worker"`
|
||||
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
@ -81,17 +83,18 @@ type WalletNoticeWorkerConfig struct {
|
||||
|
||||
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
|
||||
type RocketMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
NameServers []string `yaml:"name_servers"`
|
||||
NameServerDomain string `yaml:"name_server_domain"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
SecurityToken string `yaml:"security_token"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
VIPChannel bool `yaml:"vip_channel"`
|
||||
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
|
||||
MessageActionOutbox RoomOutboxMQConfig `yaml:"message_action_outbox"`
|
||||
}
|
||||
|
||||
// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。
|
||||
@ -151,6 +154,16 @@ func Default() Config {
|
||||
InitialBackoff: 5 * time.Second,
|
||||
MaxBackoff: 5 * time.Minute,
|
||||
},
|
||||
ConfirmNoticeWorker: WalletNoticeWorkerConfig{
|
||||
Enabled: false,
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 100,
|
||||
LockTTL: 30 * time.Second,
|
||||
PublishTimeout: 3 * time.Second,
|
||||
MaxRetryCount: 10,
|
||||
InitialBackoff: 5 * time.Second,
|
||||
MaxBackoff: 5 * time.Minute,
|
||||
},
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
@ -181,6 +194,12 @@ func defaultRocketMQConfig() RocketMQConfig {
|
||||
ConsumerGroup: "hyapp-notice-cp-user-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
MessageActionOutbox: RoomOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_message_action_outbox",
|
||||
ConsumerGroup: "hyapp-notice-message-action-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -224,6 +243,7 @@ func Load(path string) (Config, error) {
|
||||
cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
|
||||
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
|
||||
cfg.CPNoticeWorker = normalizeWalletNoticeWorker(cfg.CPNoticeWorker)
|
||||
cfg.ConfirmNoticeWorker = normalizeWalletNoticeWorker(cfg.ConfirmNoticeWorker)
|
||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@ -238,6 +258,9 @@ func Load(path string) (Config, error) {
|
||||
if cfg.CPNoticeWorker.Enabled {
|
||||
return Config{}, errors.New("cp_notice_worker direct user_outbox polling is disabled; enable rocketmq.user_outbox")
|
||||
}
|
||||
if cfg.ConfirmNoticeWorker.Enabled {
|
||||
return Config{}, errors.New("confirm_notice_worker direct message_action_outbox polling is disabled; enable rocketmq.message_action_outbox")
|
||||
}
|
||||
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
|
||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||
}
|
||||
@ -289,7 +312,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled {
|
||||
if cfg.MessageActionOutbox.Topic = strings.TrimSpace(cfg.MessageActionOutbox.Topic); cfg.MessageActionOutbox.Topic == "" {
|
||||
cfg.MessageActionOutbox.Topic = defaults.MessageActionOutbox.Topic
|
||||
}
|
||||
if cfg.MessageActionOutbox.ConsumerGroup = strings.TrimSpace(cfg.MessageActionOutbox.ConsumerGroup); cfg.MessageActionOutbox.ConsumerGroup == "" {
|
||||
cfg.MessageActionOutbox.ConsumerGroup = defaults.MessageActionOutbox.ConsumerGroup
|
||||
}
|
||||
if cfg.MessageActionOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.MessageActionOutbox.ConsumerMaxReconsumeTimes = defaults.MessageActionOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
if cfg.Enabled {
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
package confirmnotice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const (
|
||||
deliveryStatusDelivering = "delivering"
|
||||
deliveryStatusDelivered = "delivered"
|
||||
deliveryStatusRetryable = "retryable"
|
||||
deliveryStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// MySQLRepository stores confirm IM delivery state in notice_delivery_events.
|
||||
type MySQLRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
|
||||
return &MySQLRepository{db: db}
|
||||
}
|
||||
|
||||
// ClaimConfirmDelivery creates or locks one delivery row; already terminal rows are acknowledged.
|
||||
func (r *MySQLRepository) ClaimConfirmDelivery(ctx context.Context, workerID string, delivery Delivery, lockTTL time.Duration) (Delivery, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return Delivery{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return Delivery{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = 30 * time.Second
|
||||
}
|
||||
delivery.AppCode = appcode.Normalize(delivery.AppCode)
|
||||
now := time.Now()
|
||||
nowMS := now.UnixMilli()
|
||||
lockUntilMS := now.Add(lockTTL).UnixMilli()
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
status, retryCount, lockUntil, nextRetry, err := r.lockDelivery(ctx, tx, delivery)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO notice_delivery_events (
|
||||
source_name, app_code, source_event_id, channel, notice_type, target_user_id,
|
||||
status, retry_count, locked_by, lock_until_ms, next_retry_at_ms,
|
||||
payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, CAST(? AS JSON), NULL, 0, ?, ?)`,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel, delivery.NoticeType, delivery.TargetUserID,
|
||||
deliveryStatusDelivering, workerID, lockUntilMS, string(delivery.PayloadJSON), nowMS, nowMS,
|
||||
)
|
||||
if err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
delivery.RetryCount = 0
|
||||
return delivery, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
if status == deliveryStatusDelivered || status == deliveryStatusFailed {
|
||||
return Delivery{}, false, tx.Commit()
|
||||
}
|
||||
if status == deliveryStatusDelivering && lockUntil > nowMS {
|
||||
return Delivery{}, false, tx.Commit()
|
||||
}
|
||||
if status == deliveryStatusRetryable && nextRetry > nowMS {
|
||||
return Delivery{}, false, tx.Commit()
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE notice_delivery_events
|
||||
SET status = ?, notice_type = ?, target_user_id = ?, retry_count = ?, locked_by = ?,
|
||||
lock_until_ms = ?, next_retry_at_ms = 0, payload_json = CAST(? AS JSON),
|
||||
last_error = NULL, updated_at_ms = ?
|
||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
||||
deliveryStatusDelivering, delivery.NoticeType, delivery.TargetUserID, retryCount, workerID,
|
||||
lockUntilMS, string(delivery.PayloadJSON), nowMS,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
||||
)
|
||||
if err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Delivery{}, false, err
|
||||
}
|
||||
delivery.RetryCount = retryCount
|
||||
return delivery, true, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) lockDelivery(ctx context.Context, tx *sql.Tx, delivery Delivery) (string, int, int64, int64, error) {
|
||||
var status string
|
||||
var retryCount int
|
||||
var lockUntil int64
|
||||
var nextRetry int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT status, retry_count, lock_until_ms, next_retry_at_ms
|
||||
FROM notice_delivery_events
|
||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?
|
||||
FOR UPDATE`,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
||||
).Scan(&status, &retryCount, &lockUntil, &nextRetry)
|
||||
return status, retryCount, lockUntil, nextRetry, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) MarkConfirmDelivered(ctx context.Context, delivery Delivery, deliveredPayload []byte, nowMs int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE notice_delivery_events
|
||||
SET status = ?, payload_json = CAST(? AS JSON), last_error = NULL,
|
||||
delivered_at_ms = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, updated_at_ms = ?
|
||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
||||
deliveryStatusDelivered, string(deliveredPayload), nowMs, nowMs,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) MarkConfirmRetryable(ctx context.Context, delivery Delivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE notice_delivery_events
|
||||
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
|
||||
next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
|
||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
||||
deliveryStatusRetryable, retryCount, nextRetryAtMS, truncateError(lastErr), nowMs,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) MarkConfirmFailed(ctx context.Context, delivery Delivery, retryCount int, lastErr string, nowMs int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE notice_delivery_events
|
||||
SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0,
|
||||
next_retry_at_ms = 0, last_error = ?, updated_at_ms = ?
|
||||
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
|
||||
deliveryStatusFailed, retryCount, truncateError(lastErr), nowMs,
|
||||
sourceMessageActionOutbox, delivery.AppCode, delivery.EventID, delivery.Channel,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func truncateError(message string) string {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) <= 512 {
|
||||
return message
|
||||
}
|
||||
return message[:512]
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
// Package confirmnotice consumes activity-service confirm-message facts and delivers C2C IM.
|
||||
package confirmnotice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/messagemq"
|
||||
"hyapp/pkg/tencentim"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceMessageActionOutbox = "message_action_outbox"
|
||||
channelTencentIMC2C = "tencent_im_c2c"
|
||||
noticeTypeIMConfirm = "im_confirm"
|
||||
)
|
||||
|
||||
// Repository owns notice_delivery_events rows for confirm IM delivery.
|
||||
type Repository interface {
|
||||
ClaimConfirmDelivery(ctx context.Context, workerID string, delivery Delivery, lockTTL time.Duration) (Delivery, bool, error)
|
||||
MarkConfirmDelivered(ctx context.Context, delivery Delivery, deliveredPayload []byte, nowMs int64) error
|
||||
MarkConfirmRetryable(ctx context.Context, delivery Delivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
|
||||
MarkConfirmFailed(ctx context.Context, delivery Delivery, retryCount int, lastErr string, nowMs int64) error
|
||||
}
|
||||
|
||||
// Publisher is the Tencent IM C2C boundary.
|
||||
type Publisher interface {
|
||||
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
|
||||
}
|
||||
|
||||
// Config carries process-level metadata for worker IDs.
|
||||
type Config struct {
|
||||
NodeID string
|
||||
}
|
||||
|
||||
// Delivery is one concrete C2C IM side effect derived from message_action_outbox.
|
||||
type Delivery struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
ConfirmID string
|
||||
Channel string
|
||||
NoticeType string
|
||||
SenderUserID int64
|
||||
TargetUserID int64
|
||||
PayloadJSON []byte
|
||||
RetryCount int
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// Service maps confirm-message outbox facts to Tencent IM custom C2C messages.
|
||||
type Service struct {
|
||||
cfg Config
|
||||
repository Repository
|
||||
publisher Publisher
|
||||
}
|
||||
|
||||
func New(cfg Config, repository Repository, publisher Publisher) *Service {
|
||||
return &Service{cfg: cfg, repository: repository, publisher: publisher}
|
||||
}
|
||||
|
||||
// ProcessActionOutboxMessage handles one confirm-message MQ fact and acks unrelated messages.
|
||||
func (s *Service) ProcessActionOutboxMessage(ctx context.Context, message messagemq.ActionOutboxMessage, options WorkerOptions) (bool, error) {
|
||||
if message.EventType != "ConfirmMessageCreated" {
|
||||
return false, nil
|
||||
}
|
||||
options = normalizeWorkerOptions(options, s.cfg.NodeID)
|
||||
if s.repository == nil {
|
||||
return true, fmt.Errorf("notice repository is not configured")
|
||||
}
|
||||
if s.publisher == nil {
|
||||
return true, fmt.Errorf("notice publisher is not configured")
|
||||
}
|
||||
delivery, err := deliveryFromMessage(message)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
claimed, ok, err := s.repository.ClaimConfirmDelivery(ctx, options.WorkerID, delivery, options.LockTTL)
|
||||
if err != nil || !ok {
|
||||
return true, err
|
||||
}
|
||||
return true, s.publishDelivery(ctx, claimed, options)
|
||||
}
|
||||
|
||||
func (s *Service) publishDelivery(ctx context.Context, delivery Delivery, options WorkerOptions) error {
|
||||
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
|
||||
defer cancel()
|
||||
err := s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
|
||||
ToAccount: tencentim.FormatUserID(delivery.TargetUserID),
|
||||
FromAccount: tencentim.FormatUserID(delivery.SenderUserID),
|
||||
EventID: delivery.EventID,
|
||||
Desc: delivery.NoticeType,
|
||||
Ext: "im_confirm",
|
||||
PayloadJSON: delivery.PayloadJSON,
|
||||
})
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if err == nil {
|
||||
if markErr := s.repository.MarkConfirmDelivered(ctx, delivery, delivery.PayloadJSON, nowMs); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
logx.Info(ctx, "notice_confirm_delivered",
|
||||
slog.String("event_id", delivery.EventID),
|
||||
slog.String("app_code", delivery.AppCode),
|
||||
slog.String("confirm_id", delivery.ConfirmID),
|
||||
slog.Int64("target_user_id", delivery.TargetUserID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
nextRetryCount := delivery.RetryCount + 1
|
||||
if nextRetryCount >= options.MaxRetryCount {
|
||||
if markErr := s.repository.MarkConfirmFailed(ctx, delivery, nextRetryCount, err.Error(), nowMs); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
logx.Error(ctx, "notice_confirm_dead_letter", err, slog.String("event_id", delivery.EventID), slog.String("confirm_id", delivery.ConfirmID))
|
||||
return nil
|
||||
}
|
||||
nextRetryAtMS := nowMs + backoff(nextRetryCount, options).Milliseconds()
|
||||
if markErr := s.repository.MarkConfirmRetryable(ctx, delivery, nextRetryCount, nextRetryAtMS, err.Error(), nowMs); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type confirmPayload struct {
|
||||
SenderUserID string `json:"sender_user_id"`
|
||||
ReceiverUserID string `json:"receiver_user_id"`
|
||||
}
|
||||
|
||||
func deliveryFromMessage(message messagemq.ActionOutboxMessage) (Delivery, error) {
|
||||
var payload confirmPayload
|
||||
if err := json.Unmarshal([]byte(message.PayloadJSON), &payload); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
senderID, err := strconv.ParseInt(payload.SenderUserID, 10, 64)
|
||||
if err != nil || senderID <= 0 {
|
||||
return Delivery{}, fmt.Errorf("sender_user_id is invalid")
|
||||
}
|
||||
receiverID, err := strconv.ParseInt(payload.ReceiverUserID, 10, 64)
|
||||
if err != nil || receiverID <= 0 {
|
||||
return Delivery{}, fmt.Errorf("receiver_user_id is invalid")
|
||||
}
|
||||
return Delivery{
|
||||
AppCode: appcode.Normalize(message.AppCode),
|
||||
EventID: message.EventID,
|
||||
EventType: message.EventType,
|
||||
ConfirmID: message.ConfirmID,
|
||||
Channel: channelTencentIMC2C,
|
||||
NoticeType: noticeTypeIMConfirm,
|
||||
SenderUserID: senderID,
|
||||
TargetUserID: receiverID,
|
||||
PayloadJSON: []byte(message.PayloadJSON),
|
||||
CreatedAtMS: message.OccurredAtMS,
|
||||
}, nil
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package confirmnotice
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkerOptions controls MQ-driven confirm IM delivery retries.
|
||||
type WorkerOptions struct {
|
||||
WorkerID string
|
||||
BatchSize int
|
||||
LockTTL time.Duration
|
||||
PublishTimeout time.Duration
|
||||
MaxRetryCount int
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
}
|
||||
|
||||
func normalizeWorkerOptions(options WorkerOptions, nodeID string) WorkerOptions {
|
||||
if strings.TrimSpace(options.WorkerID) == "" {
|
||||
options.WorkerID = strings.TrimSpace(nodeID)
|
||||
}
|
||||
if options.WorkerID == "" {
|
||||
options.WorkerID = "notice-confirm-worker"
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
if options.LockTTL <= 0 {
|
||||
options.LockTTL = 30 * time.Second
|
||||
}
|
||||
if options.PublishTimeout <= 0 {
|
||||
options.PublishTimeout = 3 * time.Second
|
||||
}
|
||||
if options.MaxRetryCount <= 0 {
|
||||
options.MaxRetryCount = 10
|
||||
}
|
||||
if options.InitialBackoff <= 0 {
|
||||
options.InitialBackoff = 5 * time.Second
|
||||
}
|
||||
if options.MaxBackoff <= 0 {
|
||||
options.MaxBackoff = 5 * time.Minute
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func backoff(retryCount int, options WorkerOptions) time.Duration {
|
||||
if retryCount <= 0 {
|
||||
return options.InitialBackoff
|
||||
}
|
||||
delay := options.InitialBackoff
|
||||
for i := 1; i < retryCount; i++ {
|
||||
delay *= 2
|
||||
if delay >= options.MaxBackoff {
|
||||
return options.MaxBackoff
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
@ -22,6 +22,7 @@ type Repository interface {
|
||||
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
|
||||
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
|
||||
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error)
|
||||
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error)
|
||||
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
|
||||
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
|
||||
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
|
||||
@ -281,6 +282,17 @@ func (s *Service) ListRoleInvitations(ctx context.Context, command ListRoleInvit
|
||||
})
|
||||
}
|
||||
|
||||
// GetRoleInvitation 返回单条邀请事实;actor 必须是邀请发起人或目标用户,避免内部接口被误用成任意枚举入口。
|
||||
func (s *Service) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) {
|
||||
if s.repository == nil {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Unavailable, "host repository is not configured")
|
||||
}
|
||||
if actorUserID <= 0 || invitationID <= 0 {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "actor_user_id and invitation_id are required")
|
||||
}
|
||||
return s.repository.GetRoleInvitation(ctx, actorUserID, invitationID)
|
||||
}
|
||||
|
||||
// ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
|
||||
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
|
||||
if s.repository == nil {
|
||||
|
||||
@ -3,12 +3,17 @@ package host
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||
hostservice "hyapp/services/user-service/internal/service/host"
|
||||
)
|
||||
|
||||
const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated"
|
||||
|
||||
// InviteAgency 创建 Agency 角色邀请,校验 inviter BD 身份和目标用户区域。
|
||||
func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
@ -89,6 +94,9 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
||||
}); err != nil {
|
||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
@ -164,6 +172,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
||||
}); err != nil {
|
||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
@ -243,6 +254,9 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
||||
}); err != nil {
|
||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
@ -250,6 +264,101 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// GetRoleInvitation 读取单条邀请并校验 actor 只能看到自己发出或收到的邀请。
|
||||
func (r *Repository) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) {
|
||||
invitation, err := queryRoleInvitation(ctx, r.db, "WHERE invitation_id = ?", invitationID)
|
||||
if err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
}
|
||||
if actorUserID != invitation.InviterUserID && actorUserID != invitation.TargetUserID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "role invitation is not visible to actor")
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
type roleInvitationUserSnapshot struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
type roleInvitationCreatedPayload struct {
|
||||
InvitationID int64 `json:"invitation_id"`
|
||||
InvitationType string `json:"invitation_type"`
|
||||
Status string `json:"status"`
|
||||
InviterUserID int64 `json:"inviter_user_id"`
|
||||
InviterBDUserID int64 `json:"inviter_bd_user_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
AgencyName string `json:"agency_name"`
|
||||
ParentBDUserID int64 `json:"parent_bd_user_id"`
|
||||
ParentLeaderUserID int64 `json:"parent_leader_user_id"`
|
||||
Inviter roleInvitationUserSnapshot `json:"inviter"`
|
||||
Target roleInvitationUserSnapshot `json:"target"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
func insertRoleInvitationCreatedOutbox(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, nowMs int64) error {
|
||||
inviter, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.InviterUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.TargetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadBytes, err := json.Marshal(roleInvitationCreatedPayload{
|
||||
InvitationID: invitation.InvitationID,
|
||||
InvitationType: invitation.InvitationType,
|
||||
Status: invitation.Status,
|
||||
InviterUserID: invitation.InviterUserID,
|
||||
InviterBDUserID: invitation.InviterBDUserID,
|
||||
TargetUserID: invitation.TargetUserID,
|
||||
RegionID: invitation.RegionID,
|
||||
AgencyName: invitation.AgencyName,
|
||||
ParentBDUserID: invitation.ParentBDUserID,
|
||||
ParentLeaderUserID: invitation.ParentLeaderUserID,
|
||||
Inviter: inviter,
|
||||
Target: target,
|
||||
CreatedAtMS: invitation.CreatedAtMs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 邀请创建和 user_outbox 同事务提交;RocketMQ 失败时只影响异步确认 IM,业务邀请本身仍可由 outbox worker 补偿投递。
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO user_outbox (
|
||||
app_code, event_id, event_type, aggregate_type, aggregate_id,
|
||||
status, worker_id, lock_until_ms, retry_count, next_retry_at_ms,
|
||||
last_error, payload_json, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', '', 0, 0, 0, '', CAST(? AS JSON), ?, ?)`,
|
||||
appcode.FromContext(ctx),
|
||||
idgen.New("uout"),
|
||||
userOutboxEventRoleInvitationCreated,
|
||||
hostdomain.ResultTypeInvitation,
|
||||
invitation.InvitationID,
|
||||
string(payloadBytes),
|
||||
nowMs,
|
||||
nowMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func roleInvitationUserSnapshotByID(ctx context.Context, tx *sql.Tx, userID int64) (roleInvitationUserSnapshot, error) {
|
||||
var snapshot roleInvitationUserSnapshot
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT user_id, COALESCE(current_display_user_id, ''), COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id = ?`,
|
||||
appcode.FromContext(ctx), userID,
|
||||
).Scan(&snapshot.UserID, &snapshot.DisplayUserID, &snapshot.Username, &snapshot.Avatar)
|
||||
if err == sql.ErrNoRows {
|
||||
return roleInvitationUserSnapshot{}, xerr.New(xerr.NotFound, "user not found")
|
||||
}
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int64) (hostdomain.BDProfile, error) {
|
||||
if profile, ok, err := queryBDProfileMaybe(ctx, tx, userID, true); err != nil || ok {
|
||||
return profile, err
|
||||
|
||||
@ -245,6 +245,20 @@ func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleIn
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetRoleInvitation 返回一条邀请事实,用于 action-confirm 以 user-service 的 owner 状态修复确认消息。
|
||||
func (s *Server) GetRoleInvitation(ctx context.Context, req *userv1.GetRoleInvitationRequest) (*userv1.GetRoleInvitationResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
|
||||
}
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
// actor_user_id 必须是邀请发起人或目标用户;权限判断下沉到业务层,避免内部调用绕过边界。
|
||||
invitation, err := s.hostSvc.GetRoleInvitation(ctx, req.GetActorUserId(), req.GetInvitationId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.GetRoleInvitationResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
|
||||
}
|
||||
|
||||
// ProcessRoleInvitation 处理 Host、Agency 或 BD 邀请。
|
||||
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../../hyapp-h5"
|
||||
},
|
||||
{
|
||||
"path": "../../../../hyapp-flutter"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user