消息相关

This commit is contained in:
zhx 2026-06-25 19:57:16 +08:00
parent 35060cc1fc
commit bf2735d068
47 changed files with 11702 additions and 7146 deletions

File diff suppressed because it is too large Load Diff

View File

@ -178,6 +178,67 @@ message CreateFanoutJobResponse {
bool created = 3;
}
// ActionConfirm App C2C IM
message ActionConfirm {
string confirm_id = 1;
string business_type = 2;
string business_subtype = 3;
string business_id = 4;
int64 sender_user_id = 5;
int64 receiver_user_id = 6;
string status = 7;
string action = 8;
int64 processed_at_ms = 9;
int64 created_at_ms = 10;
int64 updated_at_ms = 11;
}
message AcceptActionConfirmRequest {
RequestMeta meta = 1;
string confirm_id = 2;
int64 actor_user_id = 3;
string command_id = 4;
}
message AcceptActionConfirmResponse {
ActionConfirm confirm = 1;
}
message RejectActionConfirmRequest {
RequestMeta meta = 1;
string confirm_id = 2;
int64 actor_user_id = 3;
string command_id = 4;
string reason = 5;
}
message RejectActionConfirmResponse {
ActionConfirm confirm = 1;
}
message BatchGetActionConfirmStatusRequest {
RequestMeta meta = 1;
int64 user_id = 2;
repeated string confirm_ids = 3;
}
message BatchGetActionConfirmStatusResponse {
repeated ActionConfirm items = 1;
}
message ListConversationActionConfirmsRequest {
RequestMeta meta = 1;
int64 user_id = 2;
int64 peer_user_id = 3;
int32 page_size = 4;
string page_token = 5;
}
message ListConversationActionConfirmsResponse {
repeated ActionConfirm items = 1;
string next_page_token = 2;
}
// CronBatchRequest cron-service activity-service
message CronBatchRequest {
RequestMeta meta = 1;
@ -2465,6 +2526,14 @@ service MessageInboxService {
rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse);
}
// MessageActionConfirmService IM owner service
service MessageActionConfirmService {
rpc AcceptActionConfirm(AcceptActionConfirmRequest) returns (AcceptActionConfirmResponse);
rpc RejectActionConfirm(RejectActionConfirmRequest) returns (RejectActionConfirmResponse);
rpc BatchGetActionConfirmStatus(BatchGetActionConfirmStatusRequest) returns (BatchGetActionConfirmStatusResponse);
rpc ListConversationActionConfirms(ListConversationActionConfirmsRequest) returns (ListConversationActionConfirmsResponse);
}
// ActivityCronService cron-service activity-service owner
service ActivityCronService {
rpc ProcessMessageFanoutBatch(CronBatchRequest) returns (CronBatchResponse);

View File

@ -496,6 +496,227 @@ var MessageInboxService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
MessageActionConfirmService_AcceptActionConfirm_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/AcceptActionConfirm"
MessageActionConfirmService_RejectActionConfirm_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/RejectActionConfirm"
MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/BatchGetActionConfirmStatus"
MessageActionConfirmService_ListConversationActionConfirms_FullMethodName = "/hyapp.activity.v1.MessageActionConfirmService/ListConversationActionConfirms"
)
// MessageActionConfirmServiceClient is the client API for MessageActionConfirmService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// MessageActionConfirmService 拥有 IM 操作按钮的确认状态,业务执行仍回到 owner service。
type MessageActionConfirmServiceClient interface {
AcceptActionConfirm(ctx context.Context, in *AcceptActionConfirmRequest, opts ...grpc.CallOption) (*AcceptActionConfirmResponse, error)
RejectActionConfirm(ctx context.Context, in *RejectActionConfirmRequest, opts ...grpc.CallOption) (*RejectActionConfirmResponse, error)
BatchGetActionConfirmStatus(ctx context.Context, in *BatchGetActionConfirmStatusRequest, opts ...grpc.CallOption) (*BatchGetActionConfirmStatusResponse, error)
ListConversationActionConfirms(ctx context.Context, in *ListConversationActionConfirmsRequest, opts ...grpc.CallOption) (*ListConversationActionConfirmsResponse, error)
}
type messageActionConfirmServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMessageActionConfirmServiceClient(cc grpc.ClientConnInterface) MessageActionConfirmServiceClient {
return &messageActionConfirmServiceClient{cc}
}
func (c *messageActionConfirmServiceClient) AcceptActionConfirm(ctx context.Context, in *AcceptActionConfirmRequest, opts ...grpc.CallOption) (*AcceptActionConfirmResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AcceptActionConfirmResponse)
err := c.cc.Invoke(ctx, MessageActionConfirmService_AcceptActionConfirm_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageActionConfirmServiceClient) RejectActionConfirm(ctx context.Context, in *RejectActionConfirmRequest, opts ...grpc.CallOption) (*RejectActionConfirmResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RejectActionConfirmResponse)
err := c.cc.Invoke(ctx, MessageActionConfirmService_RejectActionConfirm_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageActionConfirmServiceClient) BatchGetActionConfirmStatus(ctx context.Context, in *BatchGetActionConfirmStatusRequest, opts ...grpc.CallOption) (*BatchGetActionConfirmStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BatchGetActionConfirmStatusResponse)
err := c.cc.Invoke(ctx, MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *messageActionConfirmServiceClient) ListConversationActionConfirms(ctx context.Context, in *ListConversationActionConfirmsRequest, opts ...grpc.CallOption) (*ListConversationActionConfirmsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListConversationActionConfirmsResponse)
err := c.cc.Invoke(ctx, MessageActionConfirmService_ListConversationActionConfirms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// MessageActionConfirmServiceServer is the server API for MessageActionConfirmService service.
// All implementations must embed UnimplementedMessageActionConfirmServiceServer
// for forward compatibility.
//
// MessageActionConfirmService 拥有 IM 操作按钮的确认状态,业务执行仍回到 owner service。
type MessageActionConfirmServiceServer interface {
AcceptActionConfirm(context.Context, *AcceptActionConfirmRequest) (*AcceptActionConfirmResponse, error)
RejectActionConfirm(context.Context, *RejectActionConfirmRequest) (*RejectActionConfirmResponse, error)
BatchGetActionConfirmStatus(context.Context, *BatchGetActionConfirmStatusRequest) (*BatchGetActionConfirmStatusResponse, error)
ListConversationActionConfirms(context.Context, *ListConversationActionConfirmsRequest) (*ListConversationActionConfirmsResponse, error)
mustEmbedUnimplementedMessageActionConfirmServiceServer()
}
// UnimplementedMessageActionConfirmServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMessageActionConfirmServiceServer struct{}
func (UnimplementedMessageActionConfirmServiceServer) AcceptActionConfirm(context.Context, *AcceptActionConfirmRequest) (*AcceptActionConfirmResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AcceptActionConfirm not implemented")
}
func (UnimplementedMessageActionConfirmServiceServer) RejectActionConfirm(context.Context, *RejectActionConfirmRequest) (*RejectActionConfirmResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RejectActionConfirm not implemented")
}
func (UnimplementedMessageActionConfirmServiceServer) BatchGetActionConfirmStatus(context.Context, *BatchGetActionConfirmStatusRequest) (*BatchGetActionConfirmStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method BatchGetActionConfirmStatus not implemented")
}
func (UnimplementedMessageActionConfirmServiceServer) ListConversationActionConfirms(context.Context, *ListConversationActionConfirmsRequest) (*ListConversationActionConfirmsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListConversationActionConfirms not implemented")
}
func (UnimplementedMessageActionConfirmServiceServer) mustEmbedUnimplementedMessageActionConfirmServiceServer() {
}
func (UnimplementedMessageActionConfirmServiceServer) testEmbeddedByValue() {}
// UnsafeMessageActionConfirmServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to MessageActionConfirmServiceServer will
// result in compilation errors.
type UnsafeMessageActionConfirmServiceServer interface {
mustEmbedUnimplementedMessageActionConfirmServiceServer()
}
func RegisterMessageActionConfirmServiceServer(s grpc.ServiceRegistrar, srv MessageActionConfirmServiceServer) {
// If the following call pancis, it indicates UnimplementedMessageActionConfirmServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&MessageActionConfirmService_ServiceDesc, srv)
}
func _MessageActionConfirmService_AcceptActionConfirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AcceptActionConfirmRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageActionConfirmServiceServer).AcceptActionConfirm(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageActionConfirmService_AcceptActionConfirm_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageActionConfirmServiceServer).AcceptActionConfirm(ctx, req.(*AcceptActionConfirmRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageActionConfirmService_RejectActionConfirm_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RejectActionConfirmRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageActionConfirmServiceServer).RejectActionConfirm(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageActionConfirmService_RejectActionConfirm_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageActionConfirmServiceServer).RejectActionConfirm(ctx, req.(*RejectActionConfirmRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageActionConfirmService_BatchGetActionConfirmStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BatchGetActionConfirmStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageActionConfirmServiceServer).BatchGetActionConfirmStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageActionConfirmService_BatchGetActionConfirmStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageActionConfirmServiceServer).BatchGetActionConfirmStatus(ctx, req.(*BatchGetActionConfirmStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MessageActionConfirmService_ListConversationActionConfirms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListConversationActionConfirmsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MessageActionConfirmServiceServer).ListConversationActionConfirms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MessageActionConfirmService_ListConversationActionConfirms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MessageActionConfirmServiceServer).ListConversationActionConfirms(ctx, req.(*ListConversationActionConfirmsRequest))
}
return interceptor(ctx, in, info, handler)
}
// MessageActionConfirmService_ServiceDesc is the grpc.ServiceDesc for MessageActionConfirmService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var MessageActionConfirmService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.MessageActionConfirmService",
HandlerType: (*MessageActionConfirmServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "AcceptActionConfirm",
Handler: _MessageActionConfirmService_AcceptActionConfirm_Handler,
},
{
MethodName: "RejectActionConfirm",
Handler: _MessageActionConfirmService_RejectActionConfirm_Handler,
},
{
MethodName: "BatchGetActionConfirmStatus",
Handler: _MessageActionConfirmService_BatchGetActionConfirmStatus_Handler,
},
{
MethodName: "ListConversationActionConfirms",
Handler: _MessageActionConfirmService_ListConversationActionConfirms_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
ActivityCronService_ProcessMessageFanoutBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessMessageFanoutBatch"
ActivityCronService_ProcessLevelRewardBatch_FullMethodName = "/hyapp.activity.v1.ActivityCronService/ProcessLevelRewardBatch"

File diff suppressed because it is too large Load Diff

View File

@ -277,6 +277,16 @@ message ListRoleInvitationsResponse {
repeated RoleInvitation invitations = 1;
}
message GetRoleInvitationRequest {
RequestMeta meta = 1;
int64 actor_user_id = 2;
int64 invitation_id = 3;
}
message GetRoleInvitationResponse {
RoleInvitation invitation = 1;
}
message ProcessRoleInvitationRequest {
RequestMeta meta = 1;
string command_id = 2;
@ -513,6 +523,7 @@ service UserHostService {
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
rpc GetRoleInvitation(GetRoleInvitationRequest) returns (GetRoleInvitationResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);

View File

@ -30,6 +30,7 @@ const (
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
UserHostService_ListRoleInvitations_FullMethodName = "/hyapp.user.v1.UserHostService/ListRoleInvitations"
UserHostService_GetRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/GetRoleInvitation"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
@ -59,6 +60,7 @@ type UserHostServiceClient interface {
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error)
GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
@ -189,6 +191,16 @@ func (c *userHostServiceClient) ListRoleInvitations(ctx context.Context, in *Lis
return out, nil
}
func (c *userHostServiceClient) GetRoleInvitation(ctx context.Context, in *GetRoleInvitationRequest, opts ...grpc.CallOption) (*GetRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRoleInvitationResponse)
err := c.cc.Invoke(ctx, UserHostService_GetRoleInvitation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessRoleInvitationResponse)
@ -306,6 +318,7 @@ type UserHostServiceServer interface {
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
@ -359,6 +372,9 @@ func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListB
func (UnimplementedUserHostServiceServer) ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRoleInvitations not implemented")
}
func (UnimplementedUserHostServiceServer) GetRoleInvitation(context.Context, *GetRoleInvitationRequest) (*GetRoleInvitationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRoleInvitation not implemented")
}
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
}
@ -608,6 +624,24 @@ func _UserHostService_ListRoleInvitations_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _UserHostService_GetRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRoleInvitationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).GetRoleInvitation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_GetRoleInvitation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).GetRoleInvitation(ctx, req.(*GetRoleInvitationRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessRoleInvitationRequest)
if err := dec(in); err != nil {
@ -839,6 +873,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRoleInvitations",
Handler: _UserHostService_ListRoleInvitations_Handler,
},
{
MethodName: "GetRoleInvitation",
Handler: _UserHostService_GetRoleInvitation_Handler,
},
{
MethodName: "ProcessRoleInvitation",
Handler: _UserHostService_ProcessRoleInvitation_Handler,

View 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` 时不弹业务成功,只刷新状态后展示最终状态。

View 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
}
]
}
```
相关 IMApp 打开会话或回到前台时,收集当前页面内的 `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": ""
}
```
相关 IMApp 如果无法从本地消息列表快速收集 `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
View 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
}

View File

@ -67,7 +67,7 @@ var catalog = map[Code]Spec{
codes.FailedPrecondition,
httpStatusConflict,
DeviceAlreadyRegistered,
"conflict",
"device already registered",
),
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),

View File

@ -86,6 +86,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(CountryChangeCooldown),
publicMessage: "country change is cooling down",
},
{
name: "registered device conflict exposes concrete login reason",
code: DeviceAlreadyRegistered,
grpcCode: codes.FailedPrecondition,
httpStatus: httpStatusConflict,
publicCode: string(DeviceAlreadyRegistered),
publicMessage: "device already registered",
},
}
for _, test := range tests {

View File

@ -25,6 +25,7 @@ TOPICS=(
"hyapp_robot_room_outbox"
"hyapp_room_rocket_launch"
"hyapp_user_outbox"
"hyapp_message_action_outbox"
"hyapp_game_outbox"
)

View File

@ -24,6 +24,13 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"
outbox_max_retry: 8
publish_timeout: "5s"
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
@ -82,7 +89,14 @@ rocketmq:
consumer_group: "hyapp-activity-user-region-broadcast"
task_consumer_group: "hyapp-activity-task-user-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
producer_group: "hyapp-activity-message-action-outbox"
send_timeout: "5s"
retry: 2
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100

View File

@ -24,6 +24,13 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"
outbox_max_retry: 8
publish_timeout: "5s"
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
@ -82,7 +89,14 @@ rocketmq:
consumer_group: "hyapp-activity-user-region-broadcast"
task_consumer_group: "hyapp-activity-task-user-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
producer_group: "hyapp-activity-message-action-outbox"
send_timeout: "5s"
retry: 2
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100

View File

@ -24,6 +24,13 @@ red_packet_broadcast_worker:
enabled: true
task_event_worker:
enabled: true
message_action_confirm_worker:
enabled: true
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"
outbox_max_retry: 8
publish_timeout: "5s"
lucky_gift_worker:
enabled: true
worker_poll_interval: "1s"
@ -82,7 +89,14 @@ rocketmq:
consumer_group: "hyapp-activity-user-region-broadcast"
task_consumer_group: "hyapp-activity-task-user-outbox"
invite_activity_consumer_group: "hyapp-activity-invite-user-outbox"
message_action_consumer_group: "hyapp-activity-message-action-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
producer_group: "hyapp-activity-message-action-outbox"
send_timeout: "5s"
retry: 2
consumer:
room_outbox_poll_interval_ms: 1000
batch_size: 100

View File

@ -1201,6 +1201,55 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs (
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表';
CREATE TABLE IF NOT EXISTS message_action_confirms (
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
confirm_id VARCHAR(96) NOT NULL COMMENT 'App 操作确认 ID',
source_name VARCHAR(64) NOT NULL COMMENT '来源事实名,例如 user_outbox',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源 outbox event_id',
business_type VARCHAR(64) NOT NULL COMMENT '业务类型,例如 role_invitation',
business_subtype VARCHAR(64) NOT NULL COMMENT '业务子类型,例如 host/agency/bd',
business_id VARCHAR(128) NOT NULL COMMENT '业务 owner 的事实 ID',
sender_user_id BIGINT NOT NULL COMMENT 'IM 发送方用户 ID',
receiver_user_id BIGINT NOT NULL COMMENT 'IM 接收方用户 ID',
message_text VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'IM 展示文案快照',
status VARCHAR(32) NOT NULL COMMENT 'pending/processing/accepted/rejected/expired/canceled/failed',
action VARCHAR(32) NOT NULL DEFAULT '' COMMENT '终态动作 accept/reject',
command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '处理动作 command_id',
processed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理完成 UTC epoch ms',
locked_by VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'processing 锁持有者',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'processing 锁过期 UTC epoch ms',
error_message VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次处理失败原因',
metadata_json JSON NULL COMMENT '业务展示扩展快照',
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
PRIMARY KEY (app_code, confirm_id),
UNIQUE KEY uk_message_action_source (app_code, source_name, source_event_id),
KEY idx_message_action_receiver_status (app_code, receiver_user_id, status, created_at_ms, confirm_id),
KEY idx_message_action_conversation (app_code, receiver_user_id, sender_user_id, created_at_ms, confirm_id),
KEY idx_message_action_lock (app_code, status, lock_until_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App IM 操作确认消息状态';
CREATE TABLE IF NOT EXISTS message_action_outbox (
app_code VARCHAR(32) NOT NULL COMMENT 'App 租户编码',
event_id VARCHAR(128) NOT NULL COMMENT '确认消息 outbox 事件 ID',
confirm_id VARCHAR(96) NOT NULL COMMENT '关联 confirm_id',
event_type VARCHAR(96) NOT NULL COMMENT '事件类型,例如 ConfirmMessageCreated',
status VARCHAR(32) NOT NULL COMMENT 'pending/running/retryable/delivered/failed',
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'MQ 投递 worker ID',
lock_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MQ 投递锁过期 UTC epoch ms',
retry_count INT NOT NULL DEFAULT 0 COMMENT '投递重试次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下一次投递 UTC epoch ms',
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最后一次投递失败原因',
payload_json JSON NOT NULL COMMENT '投递给 notice-service 的确认 IM 快照',
created_at_ms BIGINT NOT NULL COMMENT '创建 UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新 UTC epoch ms',
PRIMARY KEY (app_code, event_id),
UNIQUE KEY uk_message_action_outbox_confirm (app_code, confirm_id, event_type),
KEY idx_message_action_outbox_status_created (app_code, status, next_retry_at_ms, created_at_ms, event_id),
KEY idx_message_action_outbox_lock (app_code, status, lock_until_ms, created_at_ms, event_id),
KEY idx_message_action_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='确认消息 IM 投递 outbox';
-- 本地开发必须开箱即可验证幸运礼物 v2 链路INSERT IGNORE 只补缺省奖池,不覆盖后台已发布版本。
SET @lucky_seed_now_ms := 1779259000000;
INSERT IGNORE INTO lucky_gift_rule_versions (

View File

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

View 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()
}

View File

@ -21,32 +21,38 @@ import (
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
inviteactivityservice "hyapp/services/activity-service/internal/service/inviteactivity"
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
messageservice "hyapp/services/activity-service/internal/service/message"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
)
// App 装配 activity-service 的 gRPC 入口、健康检查、后台 worker 和外部连接。
// 具体业务模块的创建和注册都放在独立文件里,避免启动入口继续膨胀成一个难维护的大函数。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
firstRechargeReward *firstrechargeservice.Service
cumulativeRecharge *cumulativerechargeservice.Service
inviteActivityReward *inviteactivityservice.Service
broadcastWorkerEnabled bool
luckyGiftWorkerEnabled bool
workerNodeID string
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
mqConsumers []*rocketmqx.Consumer
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
roomConn *grpc.ClientConn
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
broadcast *broadcastservice.Service
luckyGift *luckygiftservice.Service
firstRechargeReward *firstrechargeservice.Service
cumulativeRecharge *cumulativerechargeservice.Service
inviteActivityReward *inviteactivityservice.Service
actionConfirm *messageservice.ActionConfirmService
broadcastWorkerEnabled bool
luckyGiftWorkerEnabled bool
messageActionWorkerEnabled bool
workerNodeID string
luckyGiftWorkerOptions luckygiftservice.WorkerOptions
messageActionWorkerOptions config.MessageActionConfirmWorkerConfig
messageActionOutboxTopic string
mqConsumers []*rocketmqx.Consumer
messageActionProducer *rocketmqx.Producer
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
roomConn *grpc.ClientConn
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
// New 初始化 activity-service 应用。
@ -61,8 +67,10 @@ func New(cfg config.Config) (*App, error) {
var listener net.Listener
var clients externalClients
var mqConsumers []*rocketmqx.Consumer
var messageActionProducer *rocketmqx.Producer
cleanup := func() {
shutdownConsumers(mqConsumers)
servicemq.ShutdownProducers([]*rocketmqx.Producer{messageActionProducer})
clients.Close()
if listener != nil {
_ = listener.Close()
@ -94,6 +102,13 @@ func New(cfg config.Config) (*App, error) {
cleanup()
return nil, err
}
if cfg.RocketMQ.MessageActionOutbox.Enabled {
messageActionProducer, err = rocketmqx.NewProducer(messageActionProducerConfig(cfg.RocketMQ))
if err != nil {
cleanup()
return nil, err
}
}
health, healthHTTP, err := newHealthServers(cfg, server, repository)
if err != nil {
cleanup()
@ -101,25 +116,30 @@ func New(cfg config.Config) (*App, error) {
}
return &App{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
broadcast: services.broadcast,
luckyGift: services.luckyGift,
firstRechargeReward: services.firstRechargeReward,
cumulativeRecharge: services.cumulativeRecharge,
inviteActivityReward: services.inviteActivityReward,
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
workerNodeID: cfg.NodeID,
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
mqConsumers: mqConsumers,
userConn: clients.userConn,
walletConn: clients.walletConn,
roomConn: clients.roomConn,
workers: serviceapp.NewBackground(context.Background()),
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
broadcast: services.broadcast,
luckyGift: services.luckyGift,
firstRechargeReward: services.firstRechargeReward,
cumulativeRecharge: services.cumulativeRecharge,
inviteActivityReward: services.inviteActivityReward,
actionConfirm: services.actionConfirm,
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
luckyGiftWorkerEnabled: cfg.LuckyGiftWorker.Enabled,
messageActionWorkerEnabled: cfg.MessageActionConfirmWorker.Enabled,
workerNodeID: cfg.NodeID,
luckyGiftWorkerOptions: luckyGiftWorkerOptions(cfg.NodeID, cfg.LuckyGiftWorker),
messageActionWorkerOptions: cfg.MessageActionConfirmWorker,
messageActionOutboxTopic: cfg.RocketMQ.MessageActionOutbox.Topic,
mqConsumers: mqConsumers,
messageActionProducer: messageActionProducer,
userConn: clients.userConn,
walletConn: clients.walletConn,
roomConn: clients.roomConn,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -141,6 +161,11 @@ func (a *App) Run() error {
a.luckyGift.RunWorker(ctx, a.luckyGiftWorkerOptions)
})
}
if a.messageActionWorkerEnabled && a.actionConfirm != nil && a.messageActionProducer != nil && a.workers != nil {
a.workers.Go(func(ctx context.Context) {
a.runMessageActionOutboxWorker(ctx)
})
}
err := a.server.Serve(a.listener)
if a.workers != nil {
a.workers.StopAndWait()
@ -201,9 +226,13 @@ func (a *App) closeHealthHTTP() {
}
func (a *App) startMQ() error {
if err := servicemq.StartProducers([]*rocketmqx.Producer{a.messageActionProducer}); err != nil {
return err
}
return servicemq.StartConsumers(a.mqConsumers)
}
func (a *App) shutdownMQ() {
servicemq.ShutdownConsumers(a.mqConsumers)
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.messageActionProducer})
}

View File

@ -9,6 +9,7 @@ import (
func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(services.activity))
activityv1.RegisterMessageInboxServiceServer(server, grpcserver.NewMessageServer(services.message))
activityv1.RegisterMessageActionConfirmServiceServer(server, grpcserver.NewActionConfirmServer(services.actionConfirm))
// cron 入口只做调度适配;具体状态机仍在各 activity service 内部。
// weekly-star 和 room-turnover 都依赖 wallet 发奖,所以这里必须把同一批已装配的 service 注入 cron server。
activityv1.RegisterActivityCronServiceServer(server, grpcserver.NewCronServer(services.message, services.growth, services.achievement, services.weeklyStar, services.roomTurnoverReward, services.agencyOpening, services.cpWeeklyRank))

View File

@ -80,6 +80,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.MessageActionConfirmWorker.Enabled && cfg.RocketMQ.UserOutbox.Enabled {
consumer, err := newMessageActionUserOutboxConsumer(cfg, services)
if err != nil {
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RedPacketBroadcastWorker.Enabled && cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := newRedPacketWalletConsumer(cfg, services)
if err != nil {
@ -317,10 +325,31 @@ func userOutboxInviteActivityConsumerConfig(cfg config.RocketMQConfig) rocketmqx
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.InviteActivityConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
}
func userOutboxMessageActionConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.MessageActionConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
}
func walletOutboxConsumerConfig(cfg config.RocketMQConfig, group string) rocketmqx.ConsumerConfig {
return rocketMQConsumerConfig(cfg, group, cfg.WalletOutbox.ConsumerMaxReconsumeTimes)
}
func messageActionProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: cfg.MessageActionOutbox.ProducerGroup,
SendTimeout: cfg.MessageActionOutbox.SendTimeout,
Retry: cfg.MessageActionOutbox.Retry,
}
}
func redPacketWalletOutboxTopic(cfg config.WalletOutboxMQConfig) string {
// 钱包实时 topic 显式配置后,红包 worker 只消费实时通道,避免继续被普通账务 topic 的高频消息拖慢。
if cfg.RealtimeTopic != "" {

View File

@ -31,6 +31,7 @@ import (
type serviceBundle struct {
activity *activityservice.Service
message *messageservice.Service
actionConfirm *messageservice.ActionConfirmService
task *taskservice.Service
registrationReward *registrationrewardservice.Service
sevenDayCheckIn *sevendaycheckinservice.Service
@ -55,6 +56,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
activitySvc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(activityclient.NewGRPCUserTargetSource(clients.userConn)))
actionConfirmSvc := messageservice.NewActionConfirm(messageservice.Config{NodeID: cfg.NodeID}, repository, activityclient.NewGRPCRoleInvitationClient(clients.userConn))
taskSvc := taskservice.New(repository, walletClient)
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)
@ -106,6 +108,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
return &serviceBundle{
activity: activitySvc,
message: messageSvc,
actionConfirm: actionConfirmSvc,
task: taskSvc,
registrationReward: registrationRewardSvc,
sevenDayCheckIn: sevenDayCheckInSvc,

View File

@ -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(),
}
}

View File

@ -49,7 +49,9 @@ type Config struct {
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"`
MessageInbox MessageInboxConfig `yaml:"message_inbox"`
Log logx.Config `yaml:"log"`
// MessageActionConfirmWorker 控制通用确认消息的 user_outbox 消费和 IM outbox 发布。
MessageActionConfirmWorker MessageActionConfirmWorkerConfig `yaml:"message_action_confirm_worker"`
Log logx.Config `yaml:"log"`
}
// ConsumerConfig 保存 room outbox 消费底座配置。
@ -63,6 +65,16 @@ type MessageInboxConfig struct {
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
}
// MessageActionConfirmWorkerConfig 保存确认消息消费和 MQ 发布策略。
type MessageActionConfirmWorkerConfig struct {
Enabled bool `yaml:"enabled"`
OutboxPollInterval time.Duration `yaml:"outbox_poll_interval"`
OutboxBatchSize int `yaml:"outbox_batch_size"`
OutboxLockTTL time.Duration `yaml:"outbox_lock_ttl"`
OutboxMaxRetry int `yaml:"outbox_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
}
// FirstRechargeRewardWorkerConfig 保存钱包充值事实消费策略。
type FirstRechargeRewardWorkerConfig struct {
// Enabled 控制是否启动 wallet_outbox MQ 消费;关闭后仍可通过 gRPC 消费入口补偿。
@ -149,17 +161,18 @@ type BroadcastConfig struct {
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
type RocketMQConfig struct {
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"`
}
// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。
@ -190,9 +203,19 @@ type UserOutboxMQConfig struct {
ConsumerGroup string `yaml:"consumer_group"`
TaskConsumerGroup string `yaml:"task_consumer_group"`
InviteActivityConsumerGroup string `yaml:"invite_activity_consumer_group"`
MessageActionConsumerGroup string `yaml:"message_action_consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// MessageActionOutboxMQConfig 控制确认消息 outbox 投递到 notice-service 的 MQ producer。
type MessageActionOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ProducerGroup string `yaml:"producer_group"`
SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"`
}
// Default 返回本地默认配置。
func Default() Config {
return Config{
@ -220,6 +243,14 @@ func Default() Config {
TaskEventWorker: TaskEventWorkerConfig{
Enabled: true,
},
MessageActionConfirmWorker: MessageActionConfirmWorkerConfig{
Enabled: true,
OutboxPollInterval: time.Second,
OutboxBatchSize: 100,
OutboxLockTTL: 30 * time.Second,
OutboxMaxRetry: 8,
PublishTimeout: 5 * time.Second,
},
LuckyGiftWorker: LuckyGiftWorkerConfig{
Enabled: false,
WorkerPollInterval: time.Second,
@ -291,8 +322,16 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-activity-user-region-broadcast",
TaskConsumerGroup: "hyapp-activity-task-user-outbox",
InviteActivityConsumerGroup: "hyapp-activity-invite-user-outbox",
MessageActionConsumerGroup: "hyapp-activity-message-action-user-outbox",
ConsumerMaxReconsumeTimes: 16,
},
MessageActionOutbox: MessageActionOutboxMQConfig{
Enabled: false,
Topic: "hyapp_message_action_outbox",
ProducerGroup: "hyapp-activity-message-action-outbox",
SendTimeout: 5 * time.Second,
Retry: 2,
},
}
}
@ -386,6 +425,21 @@ func Load(path string) (Config, error) {
if cfg.LuckyGiftWorker.PublishTimeout <= 0 {
cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second
}
if cfg.MessageActionConfirmWorker.OutboxPollInterval <= 0 {
cfg.MessageActionConfirmWorker.OutboxPollInterval = time.Second
}
if cfg.MessageActionConfirmWorker.OutboxBatchSize <= 0 {
cfg.MessageActionConfirmWorker.OutboxBatchSize = 100
}
if cfg.MessageActionConfirmWorker.OutboxLockTTL <= 0 {
cfg.MessageActionConfirmWorker.OutboxLockTTL = 30 * time.Second
}
if cfg.MessageActionConfirmWorker.OutboxMaxRetry <= 0 {
cfg.MessageActionConfirmWorker.OutboxMaxRetry = 8
}
if cfg.MessageActionConfirmWorker.PublishTimeout <= 0 {
cfg.MessageActionConfirmWorker.PublishTimeout = 5 * time.Second
}
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
@ -397,6 +451,9 @@ func Load(path string) (Config, error) {
if cfg.InviteActivityRewardWorker.Enabled && !cfg.RocketMQ.UserOutbox.Enabled {
return Config{}, errors.New("invite activity reward worker requires rocketmq.user_outbox.enabled")
}
if cfg.MessageActionConfirmWorker.Enabled && (!cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.MessageActionOutbox.Enabled) {
return Config{}, errors.New("message action confirm worker requires rocketmq.user_outbox.enabled and rocketmq.message_action_outbox.enabled")
}
if cfg.LuckyGiftWorker.Enabled && !cfg.TencentIM.Enabled {
return Config{}, errors.New("lucky gift worker requires tencent_im.enabled")
}
@ -455,10 +512,25 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.UserOutbox.InviteActivityConsumerGroup = strings.TrimSpace(cfg.UserOutbox.InviteActivityConsumerGroup); cfg.UserOutbox.InviteActivityConsumerGroup == "" {
cfg.UserOutbox.InviteActivityConsumerGroup = defaults.UserOutbox.InviteActivityConsumerGroup
}
if cfg.UserOutbox.MessageActionConsumerGroup = strings.TrimSpace(cfg.UserOutbox.MessageActionConsumerGroup); cfg.UserOutbox.MessageActionConsumerGroup == "" {
cfg.UserOutbox.MessageActionConsumerGroup = defaults.UserOutbox.MessageActionConsumerGroup
}
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled {
if cfg.MessageActionOutbox.Topic = strings.TrimSpace(cfg.MessageActionOutbox.Topic); cfg.MessageActionOutbox.Topic == "" {
cfg.MessageActionOutbox.Topic = defaults.MessageActionOutbox.Topic
}
if cfg.MessageActionOutbox.ProducerGroup = strings.TrimSpace(cfg.MessageActionOutbox.ProducerGroup); cfg.MessageActionOutbox.ProducerGroup == "" {
cfg.MessageActionOutbox.ProducerGroup = defaults.MessageActionOutbox.ProducerGroup
}
if cfg.MessageActionOutbox.SendTimeout <= 0 {
cfg.MessageActionOutbox.SendTimeout = defaults.MessageActionOutbox.SendTimeout
}
if cfg.MessageActionOutbox.Retry < 0 {
cfg.MessageActionOutbox.Retry = defaults.MessageActionOutbox.Retry
}
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled {
cfg.Enabled = true
}
if cfg.Enabled {

View File

@ -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"`
}

View File

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

View File

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

View File

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

View File

@ -98,6 +98,7 @@ func New(cfg config.Config) (*App, error) {
var appRegistryClient client.AppRegistryClient = client.NewGRPCAppRegistryClient(userConn)
var walletClient client.WalletClient = client.NewGRPCWalletClient(walletConn)
var messageClient client.MessageInboxClient = client.NewGRPCMessageInboxClient(activityConn)
var messageActionClient client.MessageActionConfirmClient = client.NewGRPCMessageActionConfirmClient(activityConn)
var taskClient client.TaskClient = client.NewGRPCTaskClient(activityConn)
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
@ -173,6 +174,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetAppRegistryClient(appRegistryClient)
handler.SetWalletClient(walletClient)
handler.SetMessageInboxClient(messageClient)
handler.SetMessageActionConfirmClient(messageActionClient)
handler.SetTaskClient(taskClient)
handler.SetGrowthLevelClient(growthLevelClient)
handler.SetAchievementClient(achievementClient)

View File

@ -16,15 +16,32 @@ type MessageInboxClient interface {
DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error)
}
// MessageActionConfirmClient abstracts gateway access to generic IM action confirmations.
type MessageActionConfirmClient interface {
AcceptActionConfirm(ctx context.Context, req *activityv1.AcceptActionConfirmRequest) (*activityv1.AcceptActionConfirmResponse, error)
RejectActionConfirm(ctx context.Context, req *activityv1.RejectActionConfirmRequest) (*activityv1.RejectActionConfirmResponse, error)
BatchGetActionConfirmStatus(ctx context.Context, req *activityv1.BatchGetActionConfirmStatusRequest) (*activityv1.BatchGetActionConfirmStatusResponse, error)
ListConversationActionConfirms(ctx context.Context, req *activityv1.ListConversationActionConfirmsRequest) (*activityv1.ListConversationActionConfirmsResponse, error)
}
type grpcMessageInboxClient struct {
client activityv1.MessageInboxServiceClient
}
type grpcMessageActionConfirmClient struct {
client activityv1.MessageActionConfirmServiceClient
}
// NewGRPCMessageInboxClient builds an inbox client backed by activity-service MessageInboxService.
func NewGRPCMessageInboxClient(conn *grpc.ClientConn) MessageInboxClient {
return &grpcMessageInboxClient{client: activityv1.NewMessageInboxServiceClient(conn)}
}
// NewGRPCMessageActionConfirmClient builds an action-confirm client backed by activity-service.
func NewGRPCMessageActionConfirmClient(conn *grpc.ClientConn) MessageActionConfirmClient {
return &grpcMessageActionConfirmClient{client: activityv1.NewMessageActionConfirmServiceClient(conn)}
}
func (c *grpcMessageInboxClient) ListMessageTabs(ctx context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
return c.client.ListMessageTabs(ctx, req)
}
@ -44,3 +61,19 @@ func (c *grpcMessageInboxClient) MarkInboxSectionRead(ctx context.Context, req *
func (c *grpcMessageInboxClient) DeleteInboxMessage(ctx context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
return c.client.DeleteInboxMessage(ctx, req)
}
func (c *grpcMessageActionConfirmClient) AcceptActionConfirm(ctx context.Context, req *activityv1.AcceptActionConfirmRequest) (*activityv1.AcceptActionConfirmResponse, error) {
return c.client.AcceptActionConfirm(ctx, req)
}
func (c *grpcMessageActionConfirmClient) RejectActionConfirm(ctx context.Context, req *activityv1.RejectActionConfirmRequest) (*activityv1.RejectActionConfirmResponse, error) {
return c.client.RejectActionConfirm(ctx, req)
}
func (c *grpcMessageActionConfirmClient) BatchGetActionConfirmStatus(ctx context.Context, req *activityv1.BatchGetActionConfirmStatusRequest) (*activityv1.BatchGetActionConfirmStatusResponse, error) {
return c.client.BatchGetActionConfirmStatus(ctx, req)
}
func (c *grpcMessageActionConfirmClient) ListConversationActionConfirms(ctx context.Context, req *activityv1.ListConversationActionConfirmsRequest) (*activityv1.ListConversationActionConfirmsResponse, error) {
return c.client.ListConversationActionConfirms(ctx, req)
}

View File

@ -38,6 +38,7 @@ type Handler struct {
appRegistryClient client.AppRegistryClient
walletClient client.WalletClient
messageClient client.MessageInboxClient
messageActionClient client.MessageActionConfirmClient
taskClient client.TaskClient
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient
@ -213,6 +214,11 @@ func (h *Handler) SetMessageInboxClient(messageClient client.MessageInboxClient)
h.messageClient = messageClient
}
// SetMessageActionConfirmClient 注入 activity-service action-confirm client。
func (h *Handler) SetMessageActionConfirmClient(messageActionClient client.MessageActionConfirmClient) {
h.messageActionClient = messageActionClient
}
// SetTaskClient 注入 activity-service task client。
func (h *Handler) SetTaskClient(taskClient client.TaskClient) {
h.taskClient = taskClient

View File

@ -184,6 +184,10 @@ type MessageHandlers struct {
MarkInboxMessageReadPath http.HandlerFunc
DeleteInboxMessagePath http.HandlerFunc
ListInboxMessages http.HandlerFunc
AcceptActionConfirm http.HandlerFunc
RejectActionConfirm http.HandlerFunc
BatchActionConfirmStatus http.HandlerFunc
ListActionConfirms http.HandlerFunc
}
type TaskHandlers struct {
@ -517,6 +521,10 @@ func (r routes) registerMessageRoutes() {
r.profile("/messages/read-all", "", h.MarkInboxSectionRead)
r.profile("/messages/{message_id}/read", "", h.MarkInboxMessageReadPath)
r.profile("/messages/{message_id}", "", h.DeleteInboxMessagePath)
r.profile("/message/action-confirms/{confirm_id}/accept", http.MethodPost, h.AcceptActionConfirm)
r.profile("/message/action-confirms/{confirm_id}/reject", http.MethodPost, h.RejectActionConfirm)
r.profile("/message/action-confirms/status", http.MethodPost, h.BatchActionConfirmStatus)
r.profile("/message/action-confirms", http.MethodGet, h.ListActionConfirms)
r.profile("/messages", "", h.ListInboxMessages)
}

View File

@ -11,17 +11,20 @@ import (
// Private chat delivery remains Tencent IM owned; these handlers only expose backend-owned system inbox facts.
type Handler struct {
messageClient client.MessageInboxClient
actionClient client.MessageActionConfirmClient
userProfileClient client.UserProfileClient
}
type Config struct {
MessageClient client.MessageInboxClient
ActionClient client.MessageActionConfirmClient
UserProfileClient client.UserProfileClient
}
func New(config Config) *Handler {
return &Handler{
messageClient: config.MessageClient,
actionClient: config.ActionClient,
userProfileClient: config.UserProfileClient,
}
}
@ -33,6 +36,10 @@ func (h *Handler) MessageHandlers() httproutes.MessageHandlers {
MarkInboxMessageReadPath: h.markInboxMessageReadByPath,
DeleteInboxMessagePath: h.deleteInboxMessageByPath,
ListInboxMessages: h.listInboxMessages,
AcceptActionConfirm: h.acceptActionConfirm,
RejectActionConfirm: h.rejectActionConfirm,
BatchActionConfirmStatus: h.batchActionConfirmStatus,
ListActionConfirms: h.listActionConfirms,
}
}

View File

@ -31,6 +31,20 @@ type inboxMessageData struct {
SentAtMS int64 `json:"sent_at_ms"`
}
type actionConfirmData struct {
ConfirmID string `json:"confirm_id"`
Status string `json:"status"`
Action string `json:"action,omitempty"`
BusinessType string `json:"business_type,omitempty"`
BusinessSubtype string `json:"business_subtype,omitempty"`
BusinessID string `json:"business_id,omitempty"`
SenderUserID string `json:"sender_user_id,omitempty"`
ReceiverUserID string `json:"receiver_user_id,omitempty"`
ProcessedAtMS int64 `json:"processed_at_ms,omitempty"`
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
}
type userProfileBatchData struct {
UserID string `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
@ -195,6 +209,149 @@ func (h *Handler) deleteInboxMessage(writer http.ResponseWriter, request *http.R
httpkit.WriteOK(writer, request, map[string]any{"message_id": resp.GetMessageId(), "deleted": resp.GetDeleted()})
}
// acceptActionConfirm handles an IM confirm accept click; only confirm_id and command_id come from App.
func (h *Handler) acceptActionConfirm(writer http.ResponseWriter, request *http.Request) {
if h.actionClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
confirmID := strings.TrimSpace(request.PathValue("confirm_id"))
var body struct {
CommandID string `json:"command_id"`
}
if confirmID == "" {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.actionClient.AcceptActionConfirm(request.Context(), &activityv1.AcceptActionConfirmRequest{
Meta: httpkit.ActivityMeta(request),
ConfirmId: confirmID,
ActorUserId: auth.UserIDFromContext(request.Context()),
CommandId: body.CommandID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, actionConfirmResponseData(resp.GetConfirm()))
}
// rejectActionConfirm handles an IM confirm reject click with an optional reason.
func (h *Handler) rejectActionConfirm(writer http.ResponseWriter, request *http.Request) {
if h.actionClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
confirmID := strings.TrimSpace(request.PathValue("confirm_id"))
var body struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
}
if confirmID == "" {
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
return
}
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.actionClient.RejectActionConfirm(request.Context(), &activityv1.RejectActionConfirmRequest{
Meta: httpkit.ActivityMeta(request),
ConfirmId: confirmID,
ActorUserId: auth.UserIDFromContext(request.Context()),
CommandId: body.CommandID,
Reason: body.Reason,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, actionConfirmResponseData(resp.GetConfirm()))
}
// batchActionConfirmStatus returns confirm status for current user's visible confirm IDs.
func (h *Handler) batchActionConfirmStatus(writer http.ResponseWriter, request *http.Request) {
if h.actionClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
ConfirmIDs []string `json:"confirm_ids"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.actionClient.BatchGetActionConfirmStatus(request.Context(), &activityv1.BatchGetActionConfirmStatusRequest{
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
ConfirmIds: body.ConfirmIDs,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]actionConfirmData, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, actionConfirmResponseData(item))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items})
}
// listActionConfirms returns confirm states for a C2C peer conversation.
func (h *Handler) listActionConfirms(writer http.ResponseWriter, request *http.Request) {
if h.actionClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
peerUserID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("peer_user_id")), 10, 64)
if err != nil || peerUserID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := parseMessagePageSize(request.URL.Query().Get("page_size"))
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.actionClient.ListConversationActionConfirms(request.Context(), &activityv1.ListConversationActionConfirmsRequest{
Meta: httpkit.ActivityMeta(request),
UserId: auth.UserIDFromContext(request.Context()),
PeerUserId: peerUserID,
PageSize: pageSize,
PageToken: strings.TrimSpace(request.URL.Query().Get("page_token")),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]actionConfirmData, 0, len(resp.GetItems()))
for _, item := range resp.GetItems() {
items = append(items, actionConfirmResponseData(item))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "next_page_token": resp.GetNextPageToken()})
}
func actionConfirmResponseData(confirm *activityv1.ActionConfirm) actionConfirmData {
if confirm == nil {
return actionConfirmData{}
}
return actionConfirmData{
ConfirmID: confirm.GetConfirmId(),
Status: confirm.GetStatus(),
Action: confirm.GetAction(),
BusinessType: confirm.GetBusinessType(),
BusinessSubtype: confirm.GetBusinessSubtype(),
BusinessID: confirm.GetBusinessId(),
SenderUserID: strconv.FormatInt(confirm.GetSenderUserId(), 10),
ReceiverUserID: strconv.FormatInt(confirm.GetReceiverUserId(), 10),
ProcessedAtMS: confirm.GetProcessedAtMs(),
CreatedAtMS: confirm.GetCreatedAtMs(),
UpdatedAtMS: confirm.GetUpdatedAtMs(),
}
}
// batchUserProfiles returns only safe profile fields needed to render Tencent IM conversation rows.
func (h *Handler) batchUserProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {

View File

@ -68,6 +68,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
})
messageAPI := messageapi.New(messageapi.Config{
MessageClient: h.messageClient,
ActionClient: h.messageActionClient,
UserProfileClient: h.userProfileClient,
})
activityAPI := activityapi.New(activityapi.Config{

View File

@ -48,6 +48,16 @@ cp_notice_worker:
initial_backoff: 5s
max_backoff: 5m
confirm_notice_worker:
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
rocketmq:
# Docker 本地真实链路消费 owner outbox MQ fanout投递位点仍写 notice_delivery_events。
enabled: true
@ -71,6 +81,11 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-notice-cp-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
consumer_group: "hyapp-notice-message-action-outbox"
consumer_max_reconsume_times: 16
log:
level: info

View File

@ -48,6 +48,16 @@ cp_notice_worker:
initial_backoff: 5s
max_backoff: 5m
confirm_notice_worker:
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
rocketmq:
enabled: true
name_servers:
@ -71,6 +81,11 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-notice-cp-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
consumer_group: "hyapp-notice-message-action-outbox"
consumer_max_reconsume_times: 16
log:
level: info

View File

@ -48,6 +48,16 @@ cp_notice_worker:
initial_backoff: 5s
max_backoff: 5m
confirm_notice_worker:
enabled: false
poll_interval: 1s
batch_size: 100
lock_ttl: 30s
publish_timeout: 3s
max_retry_count: 10
initial_backoff: 5s
max_backoff: 5m
rocketmq:
# notice 只消费 owner outbox MQ fanout仍写 notice_delivery_events 做幂等。
enabled: true
@ -71,6 +81,11 @@ rocketmq:
topic: "hyapp_user_outbox"
consumer_group: "hyapp-notice-cp-user-outbox"
consumer_max_reconsume_times: 16
message_action_outbox:
enabled: true
topic: "hyapp_message_action_outbox"
consumer_group: "hyapp-notice-message-action-outbox"
consumer_max_reconsume_times: 16
log:
level: info

View File

@ -13,6 +13,7 @@ import (
"hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/messagemq"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
serviceapp "hyapp/pkg/servicekit/app"
@ -23,6 +24,7 @@ import (
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/notice-service/internal/config"
"hyapp/services/notice-service/internal/modules/confirmnotice"
"hyapp/services/notice-service/internal/modules/cpnotice"
"hyapp/services/notice-service/internal/modules/roomnotice"
"hyapp/services/notice-service/internal/modules/walletnotice"
@ -31,22 +33,24 @@ import (
// App owns the notice-service process lifecycle.
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
store *mysqlplatform.Store
walletNoticeService *walletnotice.Service
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
walletWorkerEnabled bool
roomNoticeService *roomnotice.Service
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
roomWorkerEnabled bool
cpNoticeService *cpnotice.Service
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
mqConsumers []*rocketmqx.Consumer
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
store *mysqlplatform.Store
walletNoticeService *walletnotice.Service
walletWorkerOptions walletnotice.WalletNoticeWorkerOptions
walletWorkerEnabled bool
roomNoticeService *roomnotice.Service
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
roomWorkerEnabled bool
cpNoticeService *cpnotice.Service
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
confirmNoticeService *confirmnotice.Service
confirmWorkerOptions confirmnotice.WorkerOptions
mqConsumers []*rocketmqx.Consumer
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
// New initializes MySQL, module repositories and optional Tencent IM publisher.
@ -77,6 +81,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
cpRepo := cpnotice.NewMySQLRepository(store.DB)
confirmRepo := confirmnotice.NewMySQLRepository(store.DB)
var publisher *tencentim.RESTClient
if cfg.TencentIM.Enabled {
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -112,6 +117,11 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close()
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
}
if cfg.RocketMQ.MessageActionOutbox.Enabled && publisher == nil {
_ = listener.Close()
_ = store.Close()
return nil, errors.New("message action outbox mq consumer requires tencent_im.enabled")
}
server := servicegrpc.New("notice-service")
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
@ -134,7 +144,9 @@ func New(cfg config.Config) (*App, error) {
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
cpSvc := cpnotice.New(cpnotice.Config{NodeID: cfg.NodeID, GroupIDPrefix: cfg.TencentIM.GroupIDPrefix}, cpRepo, publisher)
cpOptions := cpNoticeWorkerOptions(cfg.CPNoticeWorker)
mqConsumers := make([]*rocketmqx.Consumer, 0, 3)
confirmSvc := confirmnotice.New(confirmnotice.Config{NodeID: cfg.NodeID}, confirmRepo, publisher)
confirmOptions := confirmNoticeWorkerOptions(cfg.ConfirmNoticeWorker)
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
if cfg.RocketMQ.WalletOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ConsumerGroup, cfg.RocketMQ.WalletOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
@ -203,22 +215,47 @@ func New(cfg config.Config) (*App, error) {
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.MessageActionOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.MessageActionOutbox.ConsumerGroup, cfg.RocketMQ.MessageActionOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
shutdownConsumers(mqConsumers)
_ = listener.Close()
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.MessageActionOutbox.Topic, messagemq.TagMessageActionOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
actionMessage, err := messagemq.DecodeActionOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = confirmSvc.ProcessActionOutboxMessage(appcode.WithContext(ctx, actionMessage.AppCode), actionMessage, confirmOptions)
return err
}); err != nil {
_ = listener.Close()
_ = store.Close()
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
return &App{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
store: store,
walletNoticeService: walletSvc,
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
roomNoticeService: roomSvc,
roomWorkerOptions: roomOptions,
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
cpNoticeService: cpSvc,
cpWorkerOptions: cpOptions,
mqConsumers: mqConsumers,
workers: serviceapp.NewBackground(context.Background()),
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
store: store,
walletNoticeService: walletSvc,
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
roomNoticeService: roomSvc,
roomWorkerOptions: roomOptions,
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
cpNoticeService: cpSvc,
cpWorkerOptions: cpOptions,
confirmNoticeService: confirmSvc,
confirmWorkerOptions: confirmOptions,
mqConsumers: mqConsumers,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -325,6 +362,17 @@ func cpNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) cpnotice.CPNotic
}
}
func confirmNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) confirmnotice.WorkerOptions {
return confirmnotice.WorkerOptions{
BatchSize: cfg.BatchSize,
LockTTL: cfg.LockTTL,
PublishTimeout: cfg.PublishTimeout,
MaxRetryCount: cfg.MaxRetryCount,
InitialBackoff: cfg.InitialBackoff,
MaxBackoff: cfg.MaxBackoff,
}
}
func (a *App) startMQ() error {
return servicemq.StartConsumers(a.mqConsumers)
}

View File

@ -33,6 +33,8 @@ type Config struct {
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
// CPNoticeWorker 控制 CP user_outbox 事实转腾讯 IM 的投递锁和重试策略。
CPNoticeWorker WalletNoticeWorkerConfig `yaml:"cp_notice_worker"`
// ConfirmNoticeWorker 控制通用确认消息转腾讯 IM 的投递锁和重试策略。
ConfirmNoticeWorker WalletNoticeWorkerConfig `yaml:"confirm_notice_worker"`
// RocketMQ 消费 owner service 发布的 outbox fanout topic。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
@ -81,17 +83,18 @@ type WalletNoticeWorkerConfig struct {
// RocketMQConfig 描述 notice-service 消费 owner outbox 事实的 MQ 连接。
type RocketMQConfig struct {
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
WalletOutbox RoomOutboxMQConfig `yaml:"wallet_outbox"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
UserOutbox RoomOutboxMQConfig `yaml:"user_outbox"`
MessageActionOutbox RoomOutboxMQConfig `yaml:"message_action_outbox"`
}
// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。
@ -151,6 +154,16 @@ func Default() Config {
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
},
ConfirmNoticeWorker: WalletNoticeWorkerConfig{
Enabled: false,
PollInterval: time.Second,
BatchSize: 100,
LockTTL: 30 * time.Second,
PublishTimeout: 3 * time.Second,
MaxRetryCount: 10,
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
},
RocketMQ: defaultRocketMQConfig(),
Log: logx.Config{
Level: "info",
@ -181,6 +194,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-notice-cp-user-outbox",
ConsumerMaxReconsumeTimes: 16,
},
MessageActionOutbox: RoomOutboxMQConfig{
Enabled: false,
Topic: "hyapp_message_action_outbox",
ConsumerGroup: "hyapp-notice-message-action-outbox",
ConsumerMaxReconsumeTimes: 16,
},
}
}
@ -224,6 +243,7 @@ func Load(path string) (Config, error) {
cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
cfg.CPNoticeWorker = normalizeWalletNoticeWorker(cfg.CPNoticeWorker)
cfg.ConfirmNoticeWorker = normalizeWalletNoticeWorker(cfg.ConfirmNoticeWorker)
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
@ -238,6 +258,9 @@ func Load(path string) (Config, error) {
if cfg.CPNoticeWorker.Enabled {
return Config{}, errors.New("cp_notice_worker direct user_outbox polling is disabled; enable rocketmq.user_outbox")
}
if cfg.ConfirmNoticeWorker.Enabled {
return Config{}, errors.New("confirm_notice_worker direct message_action_outbox polling is disabled; enable rocketmq.message_action_outbox")
}
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
cfg.TencentIM.AdminIdentifier = "administrator"
}
@ -289,7 +312,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled {
if cfg.MessageActionOutbox.Topic = strings.TrimSpace(cfg.MessageActionOutbox.Topic); cfg.MessageActionOutbox.Topic == "" {
cfg.MessageActionOutbox.Topic = defaults.MessageActionOutbox.Topic
}
if cfg.MessageActionOutbox.ConsumerGroup = strings.TrimSpace(cfg.MessageActionOutbox.ConsumerGroup); cfg.MessageActionOutbox.ConsumerGroup == "" {
cfg.MessageActionOutbox.ConsumerGroup = defaults.MessageActionOutbox.ConsumerGroup
}
if cfg.MessageActionOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.MessageActionOutbox.ConsumerMaxReconsumeTimes = defaults.MessageActionOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled {
cfg.Enabled = true
}
if cfg.Enabled {

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ type Repository interface {
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error)
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error)
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
@ -281,6 +282,17 @@ func (s *Service) ListRoleInvitations(ctx context.Context, command ListRoleInvit
})
}
// GetRoleInvitation 返回单条邀请事实actor 必须是邀请发起人或目标用户,避免内部接口被误用成任意枚举入口。
func (s *Service) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) {
if s.repository == nil {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if actorUserID <= 0 || invitationID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "actor_user_id and invitation_id are required")
}
return s.repository.GetRoleInvitation(ctx, actorUserID, invitationID)
}
// ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
if s.repository == nil {

View File

@ -3,12 +3,17 @@ package host
import (
"context"
"database/sql"
"encoding/json"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
hostservice "hyapp/services/user-service/internal/service/host"
)
const userOutboxEventRoleInvitationCreated = "RoleInvitationCreated"
// InviteAgency 创建 Agency 角色邀请,校验 inviter BD 身份和目标用户区域。
func (r *Repository) InviteAgency(ctx context.Context, command hostservice.InviteAgencyCommand) (hostdomain.RoleInvitation, error) {
tx, err := r.db.BeginTx(ctx, nil)
@ -89,6 +94,9 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
@ -164,6 +172,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
@ -243,6 +254,9 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := insertRoleInvitationCreatedOutbox(ctx, tx, invitation, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
@ -250,6 +264,101 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
return invitation, nil
}
// GetRoleInvitation 读取单条邀请并校验 actor 只能看到自己发出或收到的邀请。
func (r *Repository) GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error) {
invitation, err := queryRoleInvitation(ctx, r.db, "WHERE invitation_id = ?", invitationID)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if actorUserID != invitation.InviterUserID && actorUserID != invitation.TargetUserID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "role invitation is not visible to actor")
}
return invitation, nil
}
type roleInvitationUserSnapshot struct {
UserID int64 `json:"user_id"`
DisplayUserID string `json:"display_user_id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
}
type roleInvitationCreatedPayload struct {
InvitationID int64 `json:"invitation_id"`
InvitationType string `json:"invitation_type"`
Status string `json:"status"`
InviterUserID int64 `json:"inviter_user_id"`
InviterBDUserID int64 `json:"inviter_bd_user_id"`
TargetUserID int64 `json:"target_user_id"`
RegionID int64 `json:"region_id"`
AgencyName string `json:"agency_name"`
ParentBDUserID int64 `json:"parent_bd_user_id"`
ParentLeaderUserID int64 `json:"parent_leader_user_id"`
Inviter roleInvitationUserSnapshot `json:"inviter"`
Target roleInvitationUserSnapshot `json:"target"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func insertRoleInvitationCreatedOutbox(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, nowMs int64) error {
inviter, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.InviterUserID)
if err != nil {
return err
}
target, err := roleInvitationUserSnapshotByID(ctx, tx, invitation.TargetUserID)
if err != nil {
return err
}
payloadBytes, err := json.Marshal(roleInvitationCreatedPayload{
InvitationID: invitation.InvitationID,
InvitationType: invitation.InvitationType,
Status: invitation.Status,
InviterUserID: invitation.InviterUserID,
InviterBDUserID: invitation.InviterBDUserID,
TargetUserID: invitation.TargetUserID,
RegionID: invitation.RegionID,
AgencyName: invitation.AgencyName,
ParentBDUserID: invitation.ParentBDUserID,
ParentLeaderUserID: invitation.ParentLeaderUserID,
Inviter: inviter,
Target: target,
CreatedAtMS: invitation.CreatedAtMs,
})
if err != nil {
return err
}
// 邀请创建和 user_outbox 同事务提交RocketMQ 失败时只影响异步确认 IM业务邀请本身仍可由 outbox worker 补偿投递。
_, err = tx.ExecContext(ctx, `
INSERT INTO user_outbox (
app_code, event_id, event_type, aggregate_type, aggregate_id,
status, worker_id, lock_until_ms, retry_count, next_retry_at_ms,
last_error, payload_json, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 'pending', '', 0, 0, 0, '', CAST(? AS JSON), ?, ?)`,
appcode.FromContext(ctx),
idgen.New("uout"),
userOutboxEventRoleInvitationCreated,
hostdomain.ResultTypeInvitation,
invitation.InvitationID,
string(payloadBytes),
nowMs,
nowMs,
)
return err
}
func roleInvitationUserSnapshotByID(ctx context.Context, tx *sql.Tx, userID int64) (roleInvitationUserSnapshot, error) {
var snapshot roleInvitationUserSnapshot
err := tx.QueryRowContext(ctx, `
SELECT user_id, COALESCE(current_display_user_id, ''), COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id = ?`,
appcode.FromContext(ctx), userID,
).Scan(&snapshot.UserID, &snapshot.DisplayUserID, &snapshot.Username, &snapshot.Avatar)
if err == sql.ErrNoRows {
return roleInvitationUserSnapshot{}, xerr.New(xerr.NotFound, "user not found")
}
return snapshot, err
}
func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int64) (hostdomain.BDProfile, error) {
if profile, ok, err := queryBDProfileMaybe(ctx, tx, userID, true); err != nil || ok {
return profile, err

View File

@ -245,6 +245,20 @@ func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleIn
return resp, nil
}
// GetRoleInvitation 返回一条邀请事实,用于 action-confirm 以 user-service 的 owner 状态修复确认消息。
func (s *Server) GetRoleInvitation(ctx context.Context, req *userv1.GetRoleInvitationRequest) (*userv1.GetRoleInvitationResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
// actor_user_id 必须是邀请发起人或目标用户;权限判断下沉到业务层,避免内部调用绕过边界。
invitation, err := s.hostSvc.GetRoleInvitation(ctx, req.GetActorUserId(), req.GetInvitationId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.GetRoleInvitationResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// ProcessRoleInvitation 处理 Host、Agency 或 BD 邀请。
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
if s.hostSvc == nil {

View File

@ -8,6 +8,9 @@
},
{
"path": "../../../../hyapp-h5"
},
{
"path": "../../../../hyapp-flutter"
}
]
}