2026-06-08 14:12:23 +08:00

408 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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