// Package cpnotice consumes user-service CP outbox facts and delivers the IM side effects. package cpnotice import ( "context" "encoding/json" "fmt" "log/slog" "strconv" "time" "hyapp/pkg/appcode" "hyapp/pkg/imgroup" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/pkg/usermq" ) const ( eventApplicationCreated = "UserCPApplicationCreated" eventApplicationAccepted = "UserCPApplicationAccepted" eventApplicationRejected = "UserCPApplicationRejected" eventRelationshipCreated = "UserCPRelationshipCreated" channelTencentIMC2C = "tencent_im_c2c" channelTencentIMRoom = "tencent_im_room" channelTencentIMRegion = "tencent_im_region" noticeTypeApplicationCreated = "cp_application_created" noticeTypeApplicationAccepted = "cp_application_accepted" noticeTypeApplicationRejected = "cp_application_rejected" noticeTypeRelationshipCreated = "cp_relationship_created" ) // Repository owns notice_delivery_events for CP IM deliveries; user-service remains the CP fact owner. type Repository interface { ClaimCPDelivery(ctx context.Context, workerID string, delivery CPDelivery, lockTTL time.Duration) (CPDelivery, bool, error) MarkCPDelivered(ctx context.Context, delivery CPDelivery, deliveredPayload []byte, nowMs int64) error MarkCPRetryable(ctx context.Context, delivery CPDelivery, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error MarkCPFailed(ctx context.Context, delivery CPDelivery, retryCount int, lastErr string, nowMs int64) error } // Publisher is the Tencent IM boundary needed by CP notices. type Publisher interface { PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error } // Config carries process-level delivery settings that must match gateway broadcast group generation. type Config struct { NodeID string GroupIDPrefix string } // Service maps CP facts to concrete IM messages and stores per-channel delivery state. type Service struct { cfg Config repository Repository publisher Publisher } // New creates CP notice service. func New(cfg Config, repository Repository, publisher Publisher) *Service { return &Service{cfg: cfg, repository: repository, publisher: publisher} } // ProcessUserOutboxMessage handles one user_outbox MQ message. Unknown events are acknowledged so that unrelated // user-service facts can share the same topic without notice-service poisoning the consumer group. func (s *Service) ProcessUserOutboxMessage(ctx context.Context, message usermq.UserOutboxMessage, options CPNoticeWorkerOptions) (bool, error) { if !isCPNoticeEvent(message.EventType) { return false, nil } options = normalizeCPNoticeWorkerOptions(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") } deliveries, err := deliveriesFromMessage(message, s.cfg.GroupIDPrefix) if err != nil { return true, err } for _, delivery := range deliveries { claimedDelivery, claimed, err := s.repository.ClaimCPDelivery(ctx, options.WorkerID, delivery, options.LockTTL) if err != nil || !claimed { return true, err } if err := s.publishDelivery(ctx, claimedDelivery, options); err != nil { return true, err } } return true, nil } func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions) error { publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout) defer cancel() var err error switch delivery.Channel { case channelTencentIMC2C: err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{ ToAccount: tencentim.FormatUserID(delivery.TargetUserID), FromAccount: tencentim.FormatUserID(delivery.SenderUserID), EventID: delivery.EventID, Desc: delivery.NoticeType, Ext: "cp_relation_notice", PayloadJSON: delivery.PayloadJSON, }) case channelTencentIMRoom: err = s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{ GroupID: delivery.GroupID, EventID: delivery.EventID, Desc: delivery.NoticeType, Ext: "cp_room_notice", PayloadJSON: delivery.PayloadJSON, }) case channelTencentIMRegion: // 区域群是长期播报群;发送前幂等创建,避免本地真实测试或新区域缺群导致关系主流程事实无法通知。 if ensureErr := s.publisher.EnsureGroup(publishCtx, tencentim.GroupSpec{ GroupID: delivery.GroupID, Name: delivery.GroupID, Type: tencentim.DefaultGroupType, ApplyJoinOption: "FreeAccess", }); ensureErr != nil { err = ensureErr break } err = s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{ GroupID: delivery.GroupID, EventID: delivery.EventID, Desc: delivery.NoticeType, Ext: "cp_region_broadcast", PayloadJSON: delivery.PayloadJSON, }) default: err = fmt.Errorf("unsupported cp notice channel %q", delivery.Channel) } nowMs := time.Now().UnixMilli() if err == nil { if markErr := s.repository.MarkCPDelivered(ctx, delivery, delivery.PayloadJSON, nowMs); markErr != nil { return markErr } logx.Info(ctx, "notice_cp_delivered", slog.String("event_id", delivery.EventID), slog.String("app_code", delivery.AppCode), slog.String("notice_type", delivery.NoticeType), slog.String("channel", delivery.Channel), slog.Int64("target_user_id", delivery.TargetUserID), slog.String("group_id", delivery.GroupID), ) return nil } return s.markFailed(ctx, delivery, options, err) } func (s *Service) markFailed(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions, cause error) error { nowMs := time.Now().UnixMilli() nextRetryCount := delivery.RetryCount + 1 if nextRetryCount >= options.MaxRetryCount { if err := s.repository.MarkCPFailed(ctx, delivery, nextRetryCount, cause.Error(), nowMs); err != nil { return err } logx.Error(ctx, "notice_cp_dead_letter", cause, slog.String("event_id", delivery.EventID), slog.String("app_code", delivery.AppCode), slog.String("notice_type", delivery.NoticeType), slog.String("channel", delivery.Channel), slog.Int("retry_count", nextRetryCount), ) return nil } nextRetryAtMS := nowMs + cpNoticeBackoff(nextRetryCount, options).Milliseconds() if err := s.repository.MarkCPRetryable(ctx, delivery, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil { return err } logx.Error(ctx, "notice_cp_retryable", cause, slog.String("event_id", delivery.EventID), slog.String("app_code", delivery.AppCode), slog.String("notice_type", delivery.NoticeType), slog.String("channel", delivery.Channel), slog.Int("retry_count", nextRetryCount), slog.Int64("next_retry_at_ms", nextRetryAtMS), ) return nil } func isCPNoticeEvent(eventType string) bool { switch eventType { case eventApplicationCreated, eventApplicationAccepted, eventApplicationRejected, eventRelationshipCreated: return true default: return false } } func deliveriesFromMessage(message usermq.UserOutboxMessage, groupIDPrefix string) ([]CPDelivery, error) { appCode := appcode.Normalize(message.AppCode) root, err := decodeObject(message.PayloadJSON) if err != nil { return nil, err } switch message.EventType { case eventApplicationCreated: application, err := applicationFromObject(root) if err != nil { return nil, err } basePayload, err := cpApplicationPayload(message, application, noticeTypeApplicationCreated, "pending") if err != nil { return nil, err } roomPayload := cloneJSONMap(basePayload) roomPayload["recipient_user_id"] = strconv.FormatInt(application.Target.UserID, 10) return []CPDelivery{ { AppCode: appCode, EventID: message.EventID, EventType: message.EventType, Channel: channelTencentIMC2C, NoticeType: noticeTypeApplicationCreated, SenderUserID: application.Requester.UserID, TargetUserID: application.Target.UserID, PayloadJSON: mustJSON(basePayload), CreatedAtMS: message.OccurredAtMS, }, { AppCode: appCode, EventID: message.EventID, EventType: message.EventType, Channel: channelTencentIMRoom, NoticeType: noticeTypeApplicationCreated, TargetUserID: application.Target.UserID, GroupID: application.RoomID, PayloadJSON: mustJSON(roomPayload), CreatedAtMS: message.OccurredAtMS, }, }, nil case eventApplicationAccepted: application, err := applicationFromObject(root) if err != nil { return nil, err } payload, err := cpApplicationPayload(message, application, noticeTypeApplicationAccepted, "accepted") if err != nil { return nil, err } return applicationDecisionDeliveries(appCode, message, application, payload, noticeTypeApplicationAccepted), nil case eventApplicationRejected: application, err := applicationFromObject(firstObject(root, "application", "Application")) if err != nil { return nil, err } payload, err := cpApplicationPayload(message, application, noticeTypeApplicationRejected, "rejected") if err != nil { return nil, err } payload["reason"] = stringValue(root, "reason", "Reason") return applicationDecisionDeliveries(appCode, message, application, payload, noticeTypeApplicationRejected), nil case eventRelationshipCreated: application, err := applicationFromObject(firstObject(root, "application", "Application")) if err != nil { return nil, err } relationship := firstObject(root, "relationship", "Relationship") relationshipID := stringValue(relationship, "relationship_id", "RelationshipID") groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(groupIDPrefix, appCode, application.RoomRegionID) if err != nil { return nil, err } return relationshipCreatedDelivery(appCode, message, application, relationshipID, groupID) default: return nil, nil } } func applicationDecisionDeliveries(appCode string, message usermq.UserOutboxMessage, application cpApplication, payload map[string]any, noticeType string) []CPDelivery { // 同意/拒绝结果必须同步覆盖两个入口: // 1. A 在私聊里看到 C2C 结果; // 2. A 在申请来源房间上下文看到房间 IM,客户端用 recipient_user_id 只给 A 展示按钮态变化。 deliveries := []CPDelivery{{ AppCode: appCode, EventID: message.EventID, EventType: message.EventType, Channel: channelTencentIMC2C, NoticeType: noticeType, SenderUserID: application.Target.UserID, TargetUserID: application.Requester.UserID, PayloadJSON: mustJSON(payload), CreatedAtMS: message.OccurredAtMS, }} if application.RoomID == "" { return deliveries } roomPayload := cloneJSONMap(payload) roomPayload["recipient_user_id"] = strconv.FormatInt(application.Requester.UserID, 10) deliveries = append(deliveries, CPDelivery{ AppCode: appCode, EventID: message.EventID, EventType: message.EventType, Channel: channelTencentIMRoom, NoticeType: noticeType, TargetUserID: application.Requester.UserID, GroupID: application.RoomID, PayloadJSON: mustJSON(roomPayload), CreatedAtMS: message.OccurredAtMS, }) return deliveries } func relationshipCreatedDelivery(appCode string, message usermq.UserOutboxMessage, application cpApplication, relationshipID string, groupID string) ([]CPDelivery, error) { payload, err := cpRelationshipBroadcastPayload(message, application, relationshipID, groupID) if err != nil { return nil, err } return []CPDelivery{{ AppCode: appCode, EventID: message.EventID, EventType: message.EventType, Channel: channelTencentIMRegion, NoticeType: noticeTypeRelationshipCreated, TargetUserID: 0, GroupID: groupID, PayloadJSON: mustJSON(payload), CreatedAtMS: message.OccurredAtMS, }}, nil } func cpApplicationPayload(message usermq.UserOutboxMessage, application cpApplication, noticeType string, status string) (map[string]any, error) { if application.ApplicationID == "" || application.Requester.UserID <= 0 || application.Target.UserID <= 0 { return nil, fmt.Errorf("cp application payload is incomplete") } return map[string]any{ "event_id": message.EventID, "event_type": noticeType, "source_event_type": message.EventType, "app_code": appcode.Normalize(message.AppCode), "application_id": application.ApplicationID, "relation_type": application.RelationType, "status": status, "requester": userPayload(application.Requester), "target": userPayload(application.Target), "room_id": application.RoomID, "room_region_id": application.RoomRegionID, "gift": giftPayload(application.Gift), "expires_at_ms": application.ExpiresAtMS, "decided_at_ms": application.DecidedAtMS, "source_created_at_ms": message.OccurredAtMS, }, nil } func cpRelationshipBroadcastPayload(message usermq.UserOutboxMessage, application cpApplication, relationshipID string, groupID string) (map[string]any, error) { if application.Requester.UserID <= 0 || application.Target.UserID <= 0 || application.RoomRegionID <= 0 { return nil, fmt.Errorf("cp relationship broadcast payload is incomplete") } return map[string]any{ "event_id": message.EventID, "event_type": noticeTypeRelationshipCreated, "source_event_type": message.EventType, "app_code": appcode.Normalize(message.AppCode), "relationship_id": relationshipID, "application_id": application.ApplicationID, "relation_type": application.RelationType, "requester": userPayload(application.Requester), "target": userPayload(application.Target), "gift": giftPayload(application.Gift), "room_id": application.RoomID, "room_region_id": application.RoomRegionID, "group_id": groupID, "source_created_at_ms": message.OccurredAtMS, }, nil } func userPayload(user cpUser) map[string]any { return map[string]any{ "user_id": strconv.FormatInt(user.UserID, 10), "display_user_id": user.DisplayUserID, "username": user.Username, "avatar": user.Avatar, } } func giftPayload(gift cpGift) map[string]any { return map[string]any{ "gift_id": gift.GiftID, "gift_name": gift.GiftName, "gift_icon_url": gift.GiftIconURL, "gift_animation_url": gift.GiftAnimationURL, "gift_count": gift.GiftCount, "gift_value": gift.GiftValue, "billing_receipt_id": gift.BillingReceiptID, } } func mustJSON(payload map[string]any) json.RawMessage { body, _ := json.Marshal(payload) return body } func cloneJSONMap(input map[string]any) map[string]any { out := make(map[string]any, len(input)) for key, value := range input { out[key] = value } return out }