消息相关
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;
|
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 后台批处理的统一请求。
|
// CronBatchRequest 是 cron-service 触发 activity-service 后台批处理的统一请求。
|
||||||
message CronBatchRequest {
|
message CronBatchRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
@ -2465,6 +2526,14 @@ service MessageInboxService {
|
|||||||
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
|
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 修改。
|
// ActivityCronService 只给 cron-service 调用,活动和消息状态仍由 activity-service owner 修改。
|
||||||
service ActivityCronService {
|
service ActivityCronService {
|
||||||
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);
|
||||||
|
|||||||
@ -496,6 +496,227 @@ var MessageInboxService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Metadata: "proto/activity/v1/activity.proto",
|
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 (
|
const (
|
||||||
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
|
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
|
||||||
ActivityCronService_ProcessLevelRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessLevelRewardBatch"
|
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;
|
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 {
|
message ProcessRoleInvitationRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string command_id = 2;
|
string command_id = 2;
|
||||||
@ -513,6 +523,7 @@ service UserHostService {
|
|||||||
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
|
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
|
||||||
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
|
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
|
||||||
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
|
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
|
||||||
|
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
|
||||||
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
|
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
|
||||||
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
||||||
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const (
|
|||||||
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
|
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
|
||||||
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
|
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
|
||||||
UserHostService_ListRoleInvitations_FullMethodName = "/hyapp.user.v1.UserHostService/ListRoleInvitations"
|
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_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
|
||||||
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
||||||
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
|
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)
|
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
|
||||||
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
|
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
|
||||||
ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, 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)
|
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
|
||||||
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
|
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
|
||||||
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, 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
|
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) {
|
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ProcessRoleInvitationResponse)
|
out := new(ProcessRoleInvitationResponse)
|
||||||
@ -306,6 +318,7 @@ type UserHostServiceServer interface {
|
|||||||
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
|
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
|
||||||
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
|
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
|
||||||
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
|
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
|
||||||
|
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
|
||||||
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
|
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
|
||||||
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
|
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
|
||||||
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, 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) {
|
func (UnimplementedUserHostServiceServer) ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListRoleInvitations not implemented")
|
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) {
|
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
|
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)
|
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) {
|
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ProcessRoleInvitationRequest)
|
in := new(ProcessRoleInvitationRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -839,6 +873,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListRoleInvitations",
|
MethodName: "ListRoleInvitations",
|
||||||
Handler: _UserHostService_ListRoleInvitations_Handler,
|
Handler: _UserHostService_ListRoleInvitations_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetRoleInvitation",
|
||||||
|
Handler: _UserHostService_GetRoleInvitation_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ProcessRoleInvitation",
|
MethodName: "ProcessRoleInvitation",
|
||||||
Handler: _UserHostService_ProcessRoleInvitation_Handler,
|
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,
|
codes.FailedPrecondition,
|
||||||
httpStatusConflict,
|
httpStatusConflict,
|
||||||
DeviceAlreadyRegistered,
|
DeviceAlreadyRegistered,
|
||||||
"conflict",
|
"device already registered",
|
||||||
),
|
),
|
||||||
|
|
||||||
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
||||||
|
|||||||
@ -86,6 +86,14 @@ func TestCatalogMappings(t *testing.T) {
|
|||||||
publicCode: string(CountryChangeCooldown),
|
publicCode: string(CountryChangeCooldown),
|
||||||
publicMessage: "country change is cooling down",
|
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 {
|
for _, test := range tests {
|
||||||
|
|||||||
@ -25,6 +25,7 @@ TOPICS=(
|
|||||||
"hyapp_robot_room_outbox"
|
"hyapp_robot_room_outbox"
|
||||||
"hyapp_room_rocket_launch"
|
"hyapp_room_rocket_launch"
|
||||||
"hyapp_user_outbox"
|
"hyapp_user_outbox"
|
||||||
|
"hyapp_message_action_outbox"
|
||||||
"hyapp_game_outbox"
|
"hyapp_game_outbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
task_event_worker:
|
task_event_worker:
|
||||||
enabled: true
|
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:
|
lucky_gift_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "1s"
|
worker_poll_interval: "1s"
|
||||||
@ -82,7 +89,14 @@ rocketmq:
|
|||||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||||
invite_activity_consumer_group: "hyapp-activity-invite-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
|
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:
|
consumer:
|
||||||
room_outbox_poll_interval_ms: 1000
|
room_outbox_poll_interval_ms: 1000
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
|
|||||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
task_event_worker:
|
task_event_worker:
|
||||||
enabled: true
|
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:
|
lucky_gift_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "1s"
|
worker_poll_interval: "1s"
|
||||||
@ -82,7 +89,14 @@ rocketmq:
|
|||||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||||
invite_activity_consumer_group: "hyapp-activity-invite-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
|
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:
|
consumer:
|
||||||
room_outbox_poll_interval_ms: 1000
|
room_outbox_poll_interval_ms: 1000
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
|
|||||||
@ -24,6 +24,13 @@ red_packet_broadcast_worker:
|
|||||||
enabled: true
|
enabled: true
|
||||||
task_event_worker:
|
task_event_worker:
|
||||||
enabled: true
|
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:
|
lucky_gift_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
worker_poll_interval: "1s"
|
worker_poll_interval: "1s"
|
||||||
@ -82,7 +89,14 @@ rocketmq:
|
|||||||
consumer_group: "hyapp-activity-user-region-broadcast"
|
consumer_group: "hyapp-activity-user-region-broadcast"
|
||||||
task_consumer_group: "hyapp-activity-task-user-outbox"
|
task_consumer_group: "hyapp-activity-task-user-outbox"
|
||||||
invite_activity_consumer_group: "hyapp-activity-invite-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
|
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:
|
consumer:
|
||||||
room_outbox_poll_interval_ms: 1000
|
room_outbox_poll_interval_ms: 1000
|
||||||
batch_size: 100
|
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)
|
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='消息分发任务表';
|
) 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 只补缺省奖池,不覆盖后台已发布版本。
|
-- 本地开发必须开箱即可验证幸运礼物 v2 链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布版本。
|
||||||
SET @lucky_seed_now_ms := 1779259000000;
|
SET @lucky_seed_now_ms := 1779259000000;
|
||||||
INSERT IGNORE INTO lucky_gift_rule_versions (
|
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"
|
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||||
inviteactivityservice "hyapp/services/activity-service/internal/service/inviteactivity"
|
inviteactivityservice "hyapp/services/activity-service/internal/service/inviteactivity"
|
||||||
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||||
|
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||||
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App 装配 activity-service 的 gRPC 入口、健康检查、后台 worker 和外部连接。
|
// App 装配 activity-service 的 gRPC 入口、健康检查、后台 worker 和外部连接。
|
||||||
// 具体业务模块的创建和注册都放在独立文件里,避免启动入口继续膨胀成一个难维护的大函数。
|
// 具体业务模块的创建和注册都放在独立文件里,避免启动入口继续膨胀成一个难维护的大函数。
|
||||||
type App struct {
|
type App struct {
|
||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
health *grpchealth.ServingChecker
|
health *grpchealth.ServingChecker
|
||||||
healthHTTP *healthhttp.Server
|
healthHTTP *healthhttp.Server
|
||||||
mysqlRepo *mysqlstorage.Repository
|
mysqlRepo *mysqlstorage.Repository
|
||||||
broadcast *broadcastservice.Service
|
broadcast *broadcastservice.Service
|
||||||
luckyGift *luckygiftservice.Service
|
luckyGift *luckygiftservice.Service
|
||||||
firstRechargeReward *firstrechargeservice.Service
|
firstRechargeReward *firstrechargeservice.Service
|
||||||
cumulativeRecharge *cumulativerechargeservice.Service
|
cumulativeRecharge *cumulativerechargeservice.Service
|
||||||
inviteActivityReward *inviteactivityservice.Service
|
inviteActivityReward *inviteactivityservice.Service
|
||||||
broadcastWorkerEnabled bool
|
actionConfirm *messageservice.ActionConfirmService
|
||||||
luckyGiftWorkerEnabled bool
|
broadcastWorkerEnabled bool
|
||||||
workerNodeID string
|
luckyGiftWorkerEnabled bool
|
||||||
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
|
messageActionWorkerEnabled bool
|
||||||
mqConsumers []*rocketmqx.Consumer
|
workerNodeID string
|
||||||
userConn *grpc.ClientConn
|
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
|
||||||
walletConn *grpc.ClientConn
|
messageActionWorkerOptions config.MessageActionConfirmWorkerConfig
|
||||||
roomConn *grpc.ClientConn
|
messageActionOutboxTopic string
|
||||||
workers *serviceapp.BackgroundGroup
|
mqConsumers []*rocketmqx.Consumer
|
||||||
closeOnce sync.Once
|
messageActionProducer *rocketmqx.Producer
|
||||||
|
userConn *grpc.ClientConn
|
||||||
|
walletConn *grpc.ClientConn
|
||||||
|
roomConn *grpc.ClientConn
|
||||||
|
workers *serviceapp.BackgroundGroup
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 初始化 activity-service 应用。
|
// New 初始化 activity-service 应用。
|
||||||
@ -61,8 +67,10 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
var listener net.Listener
|
var listener net.Listener
|
||||||
var clients externalClients
|
var clients externalClients
|
||||||
var mqConsumers []*rocketmqx.Consumer
|
var mqConsumers []*rocketmqx.Consumer
|
||||||
|
var messageActionProducer *rocketmqx.Producer
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
shutdownConsumers(mqConsumers)
|
shutdownConsumers(mqConsumers)
|
||||||
|
servicemq.ShutdownProducers([]*rocketmqx.Producer{messageActionProducer})
|
||||||
clients.Close()
|
clients.Close()
|
||||||
if listener != nil {
|
if listener != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
@ -94,6 +102,13 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
cleanup()
|
cleanup()
|
||||||
return nil, err
|
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)
|
health, healthHTTP, err := newHealthServers(cfg, server, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
@ -101,25 +116,30 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
server: server,
|
server: server,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
health: health,
|
health: health,
|
||||||
healthHTTP: healthHTTP,
|
healthHTTP: healthHTTP,
|
||||||
mysqlRepo: repository,
|
mysqlRepo: repository,
|
||||||
broadcast: services.broadcast,
|
broadcast: services.broadcast,
|
||||||
luckyGift: services.luckyGift,
|
luckyGift: services.luckyGift,
|
||||||
firstRechargeReward: services.firstRechargeReward,
|
firstRechargeReward: services.firstRechargeReward,
|
||||||
cumulativeRecharge: services.cumulativeRecharge,
|
cumulativeRecharge: services.cumulativeRecharge,
|
||||||
inviteActivityReward: services.inviteActivityReward,
|
inviteActivityReward: services.inviteActivityReward,
|
||||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
actionConfirm: services.actionConfirm,
|
||||||
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||||
workerNodeID: cfg.NodeID,
|
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
|
||||||
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
messageActionWorkerEnabled: cfg.MessageActionConfirmWorker.Enabled,
|
||||||
mqConsumers: mqConsumers,
|
workerNodeID: cfg.NodeID,
|
||||||
userConn: clients.userConn,
|
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
|
||||||
walletConn: clients.walletConn,
|
messageActionWorkerOptions: cfg.MessageActionConfirmWorker,
|
||||||
roomConn: clients.roomConn,
|
messageActionOutboxTopic: cfg.RocketMQ.MessageActionOutbox.Topic,
|
||||||
workers: serviceapp.NewBackground(context.Background()),
|
mqConsumers: mqConsumers,
|
||||||
|
messageActionProducer: messageActionProducer,
|
||||||
|
userConn: clients.userConn,
|
||||||
|
walletConn: clients.walletConn,
|
||||||
|
roomConn: clients.roomConn,
|
||||||
|
workers: serviceapp.NewBackground(context.Background()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,6 +161,11 @@ func (a *App) Run() error {
|
|||||||
a.luckyGift.RunWorker(ctx, a.luckyGiftWorkerOptions)
|
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)
|
err := a.server.Serve(a.listener)
|
||||||
if a.workers != nil {
|
if a.workers != nil {
|
||||||
a.workers.StopAndWait()
|
a.workers.StopAndWait()
|
||||||
@ -201,9 +226,13 @@ func (a *App) closeHealthHTTP() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startMQ() error {
|
func (a *App) startMQ() error {
|
||||||
|
if err := servicemq.StartProducers([]*rocketmqx.Producer{a.messageActionProducer}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return servicemq.StartConsumers(a.mqConsumers)
|
return servicemq.StartConsumers(a.mqConsumers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) shutdownMQ() {
|
func (a *App) shutdownMQ() {
|
||||||
servicemq.ShutdownConsumers(a.mqConsumers)
|
servicemq.ShutdownConsumers(a.mqConsumers)
|
||||||
|
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.messageActionProducer})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
|
||||||
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(services.activity))
|
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(services.activity))
|
||||||
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
|
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
|
||||||
|
activityv1.RegisterMessageActionConfirmServiceServer(server, grpcserver.NewActionConfirmServer(services.actionConfirm))
|
||||||
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
|
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
|
||||||
// weekly-star 和 room-turnover 都依赖 wallet 发奖,所以这里必须把同一批已装配的 service 注入 cron server。
|
// 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))
|
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)
|
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 {
|
if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
consumer, err := newRedPacketWalletConsumer(cfg, services)
|
consumer, err := newRedPacketWalletConsumer(cfg, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -317,10 +325,31 @@ func userOutboxInviteActivityConsumerConfig(cfg config.RocketMQConfig) rocketmqx
|
|||||||
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.InviteActivityConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
|
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 {
|
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
|
||||||
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
|
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 {
|
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
|
||||||
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
|
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
|
||||||
if cfg.RealtimeTopic != "" {
|
if cfg.RealtimeTopic != "" {
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import (
|
|||||||
type serviceBundle struct {
|
type serviceBundle struct {
|
||||||
activity *activityservice.Service
|
activity *activityservice.Service
|
||||||
message *messageservice.Service
|
message *messageservice.Service
|
||||||
|
actionConfirm *messageservice.ActionConfirmService
|
||||||
task *taskservice.Service
|
task *taskservice.Service
|
||||||
registrationReward *registrationrewardservice.Service
|
registrationReward *registrationrewardservice.Service
|
||||||
sevenDayCheckIn *sevendaycheckinservice.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)
|
activitySvc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
|
||||||
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(activityclient.NewGRPCUserTargetSource(clients.userConn)))
|
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)
|
taskSvc := taskservice.New(repository, walletClient)
|
||||||
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
|
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
|
||||||
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
|
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
|
||||||
@ -106,6 +108,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
|||||||
return &serviceBundle{
|
return &serviceBundle{
|
||||||
activity: activitySvc,
|
activity: activitySvc,
|
||||||
message: messageSvc,
|
message: messageSvc,
|
||||||
|
actionConfirm: actionConfirmSvc,
|
||||||
task: taskSvc,
|
task: taskSvc,
|
||||||
registrationReward: registrationRewardSvc,
|
registrationReward: registrationRewardSvc,
|
||||||
sevenDayCheckIn: sevenDayCheckInSvc,
|
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"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
Consumer ConsumerConfig `yaml:"consumer"`
|
Consumer ConsumerConfig `yaml:"consumer"`
|
||||||
MessageInbox MessageInboxConfig `yaml:"message_inbox"`
|
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 消费底座配置。
|
// ConsumerConfig 保存 room outbox 消费底座配置。
|
||||||
@ -63,6 +65,16 @@ type MessageInboxConfig struct {
|
|||||||
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
|
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 保存钱包充值事实消费策略。
|
// FirstRechargeRewardWorkerConfig 保存钱包充值事实消费策略。
|
||||||
type FirstRechargeRewardWorkerConfig struct {
|
type FirstRechargeRewardWorkerConfig struct {
|
||||||
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
|
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
|
||||||
@ -149,17 +161,18 @@ type BroadcastConfig struct {
|
|||||||
|
|
||||||
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
|
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
|
||||||
type RocketMQConfig struct {
|
type RocketMQConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
NameServers []string `yaml:"name_servers"`
|
NameServers []string `yaml:"name_servers"`
|
||||||
NameServerDomain string `yaml:"name_server_domain"`
|
NameServerDomain string `yaml:"name_server_domain"`
|
||||||
AccessKey string `yaml:"access_key"`
|
AccessKey string `yaml:"access_key"`
|
||||||
SecretKey string `yaml:"secret_key"`
|
SecretKey string `yaml:"secret_key"`
|
||||||
SecurityToken string `yaml:"security_token"`
|
SecurityToken string `yaml:"security_token"`
|
||||||
Namespace string `yaml:"namespace"`
|
Namespace string `yaml:"namespace"`
|
||||||
VIPChannel bool `yaml:"vip_channel"`
|
VIPChannel bool `yaml:"vip_channel"`
|
||||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||||
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
|
||||||
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
|
||||||
|
MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。
|
// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。
|
||||||
@ -190,9 +203,19 @@ type UserOutboxMQConfig struct {
|
|||||||
ConsumerGroup string `yaml:"consumer_group"`
|
ConsumerGroup string `yaml:"consumer_group"`
|
||||||
TaskConsumerGroup string `yaml:"task_consumer_group"`
|
TaskConsumerGroup string `yaml:"task_consumer_group"`
|
||||||
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
|
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
|
||||||
|
MessageActionConsumerGroup string `yaml:"message_action_consumer_group"`
|
||||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
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 返回本地默认配置。
|
// Default 返回本地默认配置。
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
@ -220,6 +243,14 @@ func Default() Config {
|
|||||||
TaskEventWorker: TaskEventWorkerConfig{
|
TaskEventWorker: TaskEventWorkerConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
MessageActionConfirmWorker: MessageActionConfirmWorkerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
OutboxPollInterval: time.Second,
|
||||||
|
OutboxBatchSize: 100,
|
||||||
|
OutboxLockTTL: 30 * time.Second,
|
||||||
|
OutboxMaxRetry: 8,
|
||||||
|
PublishTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
LuckyGiftWorker: LuckyGiftWorkerConfig{
|
LuckyGiftWorker: LuckyGiftWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WorkerPollInterval: time.Second,
|
WorkerPollInterval: time.Second,
|
||||||
@ -291,8 +322,16 @@ func defaultRocketMQConfig() RocketMQConfig {
|
|||||||
ConsumerGroup: "hyapp-activity-user-region-broadcast",
|
ConsumerGroup: "hyapp-activity-user-region-broadcast",
|
||||||
TaskConsumerGroup: "hyapp-activity-task-user-outbox",
|
TaskConsumerGroup: "hyapp-activity-task-user-outbox",
|
||||||
InviteActivityConsumerGroup: "hyapp-activity-invite-user-outbox",
|
InviteActivityConsumerGroup: "hyapp-activity-invite-user-outbox",
|
||||||
|
MessageActionConsumerGroup: "hyapp-activity-message-action-user-outbox",
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
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 {
|
if cfg.LuckyGiftWorker.PublishTimeout <= 0 {
|
||||||
cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second
|
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)
|
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@ -397,6 +451,9 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.InviteActivityRewardWorker.Enabled && !cfg.RocketMQ.UserOutbox.Enabled {
|
if cfg.InviteActivityRewardWorker.Enabled && !cfg.RocketMQ.UserOutbox.Enabled {
|
||||||
return Config{}, errors.New("invite activity reward worker requires rocketmq.user_outbox.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 {
|
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
|
||||||
return Config{}, errors.New("lucky gift worker requires tencent_im.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 == "" {
|
if cfg.UserOutbox.InviteActivityConsumerGroup = strings.TrimSpace(cfg.UserOutbox.InviteActivityConsumerGroup); cfg.UserOutbox.InviteActivityConsumerGroup == "" {
|
||||||
cfg.UserOutbox.InviteActivityConsumerGroup = defaults.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 {
|
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
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
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if cfg.Enabled {
|
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 appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
|
||||||
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
|
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
|
||||||
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
|
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
|
||||||
|
var messageActionClient client.MessageActionConfirmClient = client.NewGRPCMessageActionConfirmClient(activityConn)
|
||||||
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
|
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
|
||||||
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
|
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
|
||||||
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
||||||
@ -173,6 +174,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
handler.SetAppRegistryClient(appRegistryClient)
|
handler.SetAppRegistryClient(appRegistryClient)
|
||||||
handler.SetWalletClient(walletClient)
|
handler.SetWalletClient(walletClient)
|
||||||
handler.SetMessageInboxClient(messageClient)
|
handler.SetMessageInboxClient(messageClient)
|
||||||
|
handler.SetMessageActionConfirmClient(messageActionClient)
|
||||||
handler.SetTaskClient(taskClient)
|
handler.SetTaskClient(taskClient)
|
||||||
handler.SetGrowthLevelClient(growthLevelClient)
|
handler.SetGrowthLevelClient(growthLevelClient)
|
||||||
handler.SetAchievementClient(achievementClient)
|
handler.SetAchievementClient(achievementClient)
|
||||||
|
|||||||
@ -16,15 +16,32 @@ type MessageInboxClient interface {
|
|||||||
DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error)
|
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 {
|
type grpcMessageInboxClient struct {
|
||||||
client activityv1.MessageInboxServiceClient
|
client activityv1.MessageInboxServiceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type grpcMessageActionConfirmClient struct {
|
||||||
|
client activityv1.MessageActionConfirmServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
// NewGRPCMessageInboxClient builds an inbox client backed by activity-service MessageInboxService.
|
// NewGRPCMessageInboxClient builds an inbox client backed by activity-service MessageInboxService.
|
||||||
func NewGRPCMessageInboxClient(conn *grpc.ClientConn) MessageInboxClient {
|
func NewGRPCMessageInboxClient(conn *grpc.ClientConn) MessageInboxClient {
|
||||||
return &grpcMessageInboxClient{client: activityv1.NewMessageInboxServiceClient(conn)}
|
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) {
|
func (c *grpcMessageInboxClient) ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
|
||||||
return c.client.ListMessageTabs(ctx, req)
|
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) {
|
func (c *grpcMessageInboxClient) DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
|
||||||
return c.client.DeleteInboxMessage(ctx, req)
|
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
|
appRegistryClient client.AppRegistryClient
|
||||||
walletClient client.WalletClient
|
walletClient client.WalletClient
|
||||||
messageClient client.MessageInboxClient
|
messageClient client.MessageInboxClient
|
||||||
|
messageActionClient client.MessageActionConfirmClient
|
||||||
taskClient client.TaskClient
|
taskClient client.TaskClient
|
||||||
growthLevelClient client.GrowthLevelClient
|
growthLevelClient client.GrowthLevelClient
|
||||||
achievementClient client.AchievementClient
|
achievementClient client.AchievementClient
|
||||||
@ -213,6 +214,11 @@ func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient)
|
|||||||
h.messageClient = messageClient
|
h.messageClient = messageClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMessageActionConfirmClient 注入 activity-service action-confirm client。
|
||||||
|
func (h *Handler) SetMessageActionConfirmClient(messageActionClient client.MessageActionConfirmClient) {
|
||||||
|
h.messageActionClient = messageActionClient
|
||||||
|
}
|
||||||
|
|
||||||
// SetTaskClient 注入 activity-service task client。
|
// SetTaskClient 注入 activity-service task client。
|
||||||
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
|
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
|
||||||
h.taskClient = taskClient
|
h.taskClient = taskClient
|
||||||
|
|||||||
@ -184,6 +184,10 @@ type MessageHandlers struct {
|
|||||||
MarkInboxMessageReadPath http.HandlerFunc
|
MarkInboxMessageReadPath http.HandlerFunc
|
||||||
DeleteInboxMessagePath http.HandlerFunc
|
DeleteInboxMessagePath http.HandlerFunc
|
||||||
ListInboxMessages http.HandlerFunc
|
ListInboxMessages http.HandlerFunc
|
||||||
|
AcceptActionConfirm http.HandlerFunc
|
||||||
|
RejectActionConfirm http.HandlerFunc
|
||||||
|
BatchActionConfirmStatus http.HandlerFunc
|
||||||
|
ListActionConfirms http.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskHandlers struct {
|
type TaskHandlers struct {
|
||||||
@ -517,6 +521,10 @@ func (r routes) registerMessageRoutes() {
|
|||||||
r.profile("/messages/read-all", "", h.MarkInboxSectionRead)
|
r.profile("/messages/read-all", "", h.MarkInboxSectionRead)
|
||||||
r.profile("/messages/{message_id}/read", "", h.MarkInboxMessageReadPath)
|
r.profile("/messages/{message_id}/read", "", h.MarkInboxMessageReadPath)
|
||||||
r.profile("/messages/{message_id}", "", h.DeleteInboxMessagePath)
|
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)
|
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.
|
// Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
messageClient client.MessageInboxClient
|
messageClient client.MessageInboxClient
|
||||||
|
actionClient client.MessageActionConfirmClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
MessageClient client.MessageInboxClient
|
MessageClient client.MessageInboxClient
|
||||||
|
ActionClient client.MessageActionConfirmClient
|
||||||
UserProfileClient client.UserProfileClient
|
UserProfileClient client.UserProfileClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
messageClient: config.MessageClient,
|
messageClient: config.MessageClient,
|
||||||
|
actionClient: config.ActionClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,6 +36,10 @@ func (h *Handler) MessageHandlers() httproutes.MessageHandlers {
|
|||||||
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
|
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
|
||||||
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
|
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
|
||||||
ListInboxMessages: h.listInboxMessages,
|
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"`
|
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 {
|
type userProfileBatchData struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
DisplayUserID string `json:"display_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()})
|
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.
|
// batchUserProfiles returns only safe profile fields needed to render Tencent IM conversation rows.
|
||||||
func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.userProfileClient == nil {
|
if h.userProfileClient == nil {
|
||||||
|
|||||||
@ -68,6 +68,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
})
|
})
|
||||||
messageAPI := messageapi.New(messageapi.Config{
|
messageAPI := messageapi.New(messageapi.Config{
|
||||||
MessageClient: h.messageClient,
|
MessageClient: h.messageClient,
|
||||||
|
ActionClient: h.messageActionClient,
|
||||||
UserProfileClient: h.userProfileClient,
|
UserProfileClient: h.userProfileClient,
|
||||||
})
|
})
|
||||||
activityAPI := activityapi.New(activityapi.Config{
|
activityAPI := activityapi.New(activityapi.Config{
|
||||||
|
|||||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
|||||||
initial_backoff: 5s
|
initial_backoff: 5s
|
||||||
max_backoff: 5m
|
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:
|
rocketmq:
|
||||||
# Docker 本地真实链路消费 owner outbox MQ fanout,投递位点仍写 notice_delivery_events。
|
# Docker 本地真实链路消费 owner outbox MQ fanout,投递位点仍写 notice_delivery_events。
|
||||||
enabled: true
|
enabled: true
|
||||||
@ -71,6 +81,11 @@ rocketmq:
|
|||||||
topic: "hyapp_user_outbox"
|
topic: "hyapp_user_outbox"
|
||||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
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:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
|||||||
initial_backoff: 5s
|
initial_backoff: 5s
|
||||||
max_backoff: 5m
|
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:
|
rocketmq:
|
||||||
enabled: true
|
enabled: true
|
||||||
name_servers:
|
name_servers:
|
||||||
@ -71,6 +81,11 @@ rocketmq:
|
|||||||
topic: "hyapp_user_outbox"
|
topic: "hyapp_user_outbox"
|
||||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
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:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@ -48,6 +48,16 @@ cp_notice_worker:
|
|||||||
initial_backoff: 5s
|
initial_backoff: 5s
|
||||||
max_backoff: 5m
|
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:
|
rocketmq:
|
||||||
# notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
# notice 只消费 owner outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
||||||
enabled: true
|
enabled: true
|
||||||
@ -71,6 +81,11 @@ rocketmq:
|
|||||||
topic: "hyapp_user_outbox"
|
topic: "hyapp_user_outbox"
|
||||||
consumer_group: "hyapp-notice-cp-user-outbox"
|
consumer_group: "hyapp-notice-cp-user-outbox"
|
||||||
consumer_max_reconsume_times: 16
|
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:
|
log:
|
||||||
level: info
|
level: info
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"hyapp/pkg/grpchealth"
|
"hyapp/pkg/grpchealth"
|
||||||
"hyapp/pkg/healthhttp"
|
"hyapp/pkg/healthhttp"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/pkg/messagemq"
|
||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
"hyapp/pkg/roommq"
|
"hyapp/pkg/roommq"
|
||||||
serviceapp "hyapp/pkg/servicekit/app"
|
serviceapp "hyapp/pkg/servicekit/app"
|
||||||
@ -23,6 +24,7 @@ import (
|
|||||||
"hyapp/pkg/usermq"
|
"hyapp/pkg/usermq"
|
||||||
"hyapp/pkg/walletmq"
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/services/notice-service/internal/config"
|
"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/cpnotice"
|
||||||
"hyapp/services/notice-service/internal/modules/roomnotice"
|
"hyapp/services/notice-service/internal/modules/roomnotice"
|
||||||
"hyapp/services/notice-service/internal/modules/walletnotice"
|
"hyapp/services/notice-service/internal/modules/walletnotice"
|
||||||
@ -31,22 +33,24 @@ import (
|
|||||||
|
|
||||||
// App owns the notice-service process lifecycle.
|
// App owns the notice-service process lifecycle.
|
||||||
type App struct {
|
type App struct {
|
||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
health *grpchealth.ServingChecker
|
health *grpchealth.ServingChecker
|
||||||
healthHTTP *healthhttp.Server
|
healthHTTP *healthhttp.Server
|
||||||
store *mysqlplatform.Store
|
store *mysqlplatform.Store
|
||||||
walletNoticeService *walletnotice.Service
|
walletNoticeService *walletnotice.Service
|
||||||
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
|
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
|
||||||
walletWorkerEnabled bool
|
walletWorkerEnabled bool
|
||||||
roomNoticeService *roomnotice.Service
|
roomNoticeService *roomnotice.Service
|
||||||
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
|
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
|
||||||
roomWorkerEnabled bool
|
roomWorkerEnabled bool
|
||||||
cpNoticeService *cpnotice.Service
|
cpNoticeService *cpnotice.Service
|
||||||
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
|
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
|
||||||
mqConsumers []*rocketmqx.Consumer
|
confirmNoticeService *confirmnotice.Service
|
||||||
workers *serviceapp.BackgroundGroup
|
confirmWorkerOptions confirmnotice.WorkerOptions
|
||||||
closeOnce sync.Once
|
mqConsumers []*rocketmqx.Consumer
|
||||||
|
workers *serviceapp.BackgroundGroup
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New initializes MySQL, module repositories and optional Tencent IM publisher.
|
// New initializes MySQL, module repositories and optional Tencent IM publisher.
|
||||||
@ -77,6 +81,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cpRepo := cpnotice.NewMySQLRepository(store.DB)
|
cpRepo := cpnotice.NewMySQLRepository(store.DB)
|
||||||
|
confirmRepo := confirmnotice.NewMySQLRepository(store.DB)
|
||||||
var publisher *tencentim.RESTClient
|
var publisher *tencentim.RESTClient
|
||||||
if cfg.TencentIM.Enabled {
|
if cfg.TencentIM.Enabled {
|
||||||
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||||
@ -112,6 +117,11 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = store.Close()
|
_ = store.Close()
|
||||||
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
|
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")
|
server := servicegrpc.New("notice-service")
|
||||||
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
||||||
@ -134,7 +144,9 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
||||||
cpSvc := cpnotice.New(cpnotice.Config{NodeID: cfg.NodeID, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix}, cpRepo, publisher)
|
cpSvc := cpnotice.New(cpnotice.Config{NodeID: cfg.NodeID, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix}, cpRepo, publisher)
|
||||||
cpOptions := cpNoticeWorkerOptions(cfg.CPNoticeWorker)
|
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 {
|
if cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
|
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -203,22 +215,47 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
mqConsumers = append(mqConsumers, consumer)
|
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{
|
return &App{
|
||||||
server: server,
|
server: server,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
health: health,
|
health: health,
|
||||||
healthHTTP: healthHTTP,
|
healthHTTP: healthHTTP,
|
||||||
store: store,
|
store: store,
|
||||||
walletNoticeService: walletSvc,
|
walletNoticeService: walletSvc,
|
||||||
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
|
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
|
||||||
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
|
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
|
||||||
roomNoticeService: roomSvc,
|
roomNoticeService: roomSvc,
|
||||||
roomWorkerOptions: roomOptions,
|
roomWorkerOptions: roomOptions,
|
||||||
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
|
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
|
||||||
cpNoticeService: cpSvc,
|
cpNoticeService: cpSvc,
|
||||||
cpWorkerOptions: cpOptions,
|
cpWorkerOptions: cpOptions,
|
||||||
mqConsumers: mqConsumers,
|
confirmNoticeService: confirmSvc,
|
||||||
workers: serviceapp.NewBackground(context.Background()),
|
confirmWorkerOptions: confirmOptions,
|
||||||
|
mqConsumers: mqConsumers,
|
||||||
|
workers: serviceapp.NewBackground(context.Background()),
|
||||||
}, nil
|
}, 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 {
|
func (a *App) startMQ() error {
|
||||||
return servicemq.StartConsumers(a.mqConsumers)
|
return servicemq.StartConsumers(a.mqConsumers)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@ type Config struct {
|
|||||||
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
||||||
// CPNoticeWorker 控制 CP user_outbox 事实转腾讯 IM 的投递锁和重试策略。
|
// CPNoticeWorker 控制 CP user_outbox 事实转腾讯 IM 的投递锁和重试策略。
|
||||||
CPNoticeWorker WalletNoticeWorkerConfig `yaml:"cp_notice_worker"`
|
CPNoticeWorker WalletNoticeWorkerConfig `yaml:"cp_notice_worker"`
|
||||||
|
// ConfirmNoticeWorker 控制通用确认消息转腾讯 IM 的投递锁和重试策略。
|
||||||
|
ConfirmNoticeWorker WalletNoticeWorkerConfig `yaml:"confirm_notice_worker"`
|
||||||
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
|
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
|
||||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
@ -81,17 +83,18 @@ type WalletNoticeWorkerConfig struct {
|
|||||||
|
|
||||||
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
|
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
|
||||||
type RocketMQConfig struct {
|
type RocketMQConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
NameServers []string `yaml:"name_servers"`
|
NameServers []string `yaml:"name_servers"`
|
||||||
NameServerDomain string `yaml:"name_server_domain"`
|
NameServerDomain string `yaml:"name_server_domain"`
|
||||||
AccessKey string `yaml:"access_key"`
|
AccessKey string `yaml:"access_key"`
|
||||||
SecretKey string `yaml:"secret_key"`
|
SecretKey string `yaml:"secret_key"`
|
||||||
SecurityToken string `yaml:"security_token"`
|
SecurityToken string `yaml:"security_token"`
|
||||||
Namespace string `yaml:"namespace"`
|
Namespace string `yaml:"namespace"`
|
||||||
VIPChannel bool `yaml:"vip_channel"`
|
VIPChannel bool `yaml:"vip_channel"`
|
||||||
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
|
||||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||||
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
|
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
|
||||||
|
MessageActionOutbox RoomOutboxMQConfig `yaml:"message_action_outbox"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。
|
// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。
|
||||||
@ -151,6 +154,16 @@ func Default() Config {
|
|||||||
InitialBackoff: 5 * time.Second,
|
InitialBackoff: 5 * time.Second,
|
||||||
MaxBackoff: 5 * time.Minute,
|
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(),
|
RocketMQ: defaultRocketMQConfig(),
|
||||||
Log: logx.Config{
|
Log: logx.Config{
|
||||||
Level: "info",
|
Level: "info",
|
||||||
@ -181,6 +194,12 @@ func defaultRocketMQConfig() RocketMQConfig {
|
|||||||
ConsumerGroup: "hyapp-notice-cp-user-outbox",
|
ConsumerGroup: "hyapp-notice-cp-user-outbox",
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
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.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
|
||||||
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
|
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
|
||||||
cfg.CPNoticeWorker = normalizeWalletNoticeWorker(cfg.CPNoticeWorker)
|
cfg.CPNoticeWorker = normalizeWalletNoticeWorker(cfg.CPNoticeWorker)
|
||||||
|
cfg.ConfirmNoticeWorker = normalizeWalletNoticeWorker(cfg.ConfirmNoticeWorker)
|
||||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@ -238,6 +258,9 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.CPNoticeWorker.Enabled {
|
if cfg.CPNoticeWorker.Enabled {
|
||||||
return Config{}, errors.New("cp_notice_worker direct user_outbox polling is disabled; enable rocketmq.user_outbox")
|
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) == "" {
|
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
|
||||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||||
}
|
}
|
||||||
@ -289,7 +312,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
|||||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
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
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if cfg.Enabled {
|
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)
|
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
|
||||||
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
|
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
|
||||||
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]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)
|
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
|
||||||
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
|
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
|
||||||
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]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 列表。
|
// ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
|
||||||
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
|
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
|
||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
|
|||||||
@ -3,12 +3,17 @@ package host
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/idgen"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
hostdomain "hyapp/services/user-service/internal/domain/host"
|
hostdomain "hyapp/services/user-service/internal/domain/host"
|
||||||
hostservice "hyapp/services/user-service/internal/service/host"
|
hostservice "hyapp/services/user-service/internal/service/host"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated"
|
||||||
|
|
||||||
// InviteAgency 创建 Agency 角色邀请,校验 inviter BD 身份和目标用户区域。
|
// InviteAgency 创建 Agency 角色邀请,校验 inviter BD 身份和目标用户区域。
|
||||||
func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) {
|
func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
@ -89,6 +94,9 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
@ -164,6 +172,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
@ -243,6 +254,9 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
@ -250,6 +264,101 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
|||||||
return invitation, nil
|
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) {
|
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 {
|
if profile, ok, err := queryBDProfileMaybe(ctx, tx, userID, true); err != nil || ok {
|
||||||
return profile, err
|
return profile, err
|
||||||
|
|||||||
@ -245,6 +245,20 @@ func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleIn
|
|||||||
return resp, nil
|
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 邀请。
|
// ProcessRoleInvitation 处理 Host、Agency 或 BD 邀请。
|
||||||
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
|
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
|
||||||
if s.hostSvc == nil {
|
if s.hostSvc == nil {
|
||||||
|
|||||||
@ -8,6 +8,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../../hyapp-h5"
|
"path": "../../../../hyapp-h5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../../../hyapp-flutter"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user