517 lines
20 KiB
Go
517 lines
20 KiB
Go
// 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"
|
||
channelTencentIMC2CAcceptor = "tencent_im_c2c_acceptor"
|
||
channelTencentIMRoom = "tencent_im_room"
|
||
channelTencentIMRoomSender = "tencent_im_room_sender"
|
||
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, channelTencentIMC2CAcceptor:
|
||
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, channelTencentIMRoomSender:
|
||
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, s.regionGroupSpec(delivery.GroupID)); 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,
|
||
})
|
||
if err == nil {
|
||
s.dualSendLegacyRegion(publishCtx, delivery)
|
||
}
|
||
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)
|
||
}
|
||
|
||
// regionGroupSpec 按群 ID 版本决定建群参数:v2 区域群是 AVChatRoom(ChatRoom 套餐成员上限
|
||
// 曾在线上触发 10038 拒绝加群),迁移前入队的存量 v1 投递记录仍按 ChatRoom 幂等创建。
|
||
func (s *Service) regionGroupSpec(groupID string) tencentim.GroupSpec {
|
||
parsed := imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix)
|
||
if parsed.Kind == imgroup.KindRegionBroadcast {
|
||
if legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID); err == nil && legacyID != groupID {
|
||
return tencentim.GroupSpec{
|
||
GroupID: groupID,
|
||
Name: groupID,
|
||
Type: tencentim.BroadcastGroupType,
|
||
}
|
||
}
|
||
}
|
||
return tencentim.GroupSpec{
|
||
GroupID: groupID,
|
||
Name: groupID,
|
||
Type: tencentim.DefaultGroupType,
|
||
ApplyJoinOption: "FreeAccess",
|
||
}
|
||
}
|
||
|
||
// dualSendLegacyRegion 在区域播报群 v1→v2 过渡期内,把已成功投递到 v2 群的 CP 播报补发到 v1 旧群,
|
||
// 覆盖尚未重新拉取 join 列表的老客户端。补发尽力而为,失败不影响主投递状态,与 activity 播报双发同一模式。
|
||
func (s *Service) dualSendLegacyRegion(ctx context.Context, delivery CPDelivery) {
|
||
parsed := imgroup.ParseWithPrefix(delivery.GroupID, s.cfg.GroupIDPrefix)
|
||
if parsed.Kind != imgroup.KindRegionBroadcast {
|
||
return
|
||
}
|
||
legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
|
||
if err != nil || legacyID == delivery.GroupID {
|
||
return
|
||
}
|
||
_ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
|
||
GroupID: legacyID,
|
||
EventID: delivery.EventID + "_legacy",
|
||
Desc: delivery.NoticeType,
|
||
Ext: "cp_region_broadcast",
|
||
PayloadJSON: delivery.PayloadJSON,
|
||
})
|
||
}
|
||
|
||
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)
|
||
requesterRoomPayload := cloneJSONMap(basePayload)
|
||
requesterRoomPayload["recipient_user_id"] = strconv.FormatInt(application.Requester.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,
|
||
},
|
||
{
|
||
// 创建申请时 target 需要接收态按钮,requester 也需要同一房间上下文里的等待态弹窗。
|
||
// notice_delivery_events 的主键包含 channel,同一 event_id 不能复用 tencent_im_room,
|
||
// 因此发起人房间通知用独立内部 channel,发布边界仍走腾讯 IM 群自定义消息。
|
||
AppCode: appCode,
|
||
EventID: message.EventID,
|
||
EventType: message.EventType,
|
||
Channel: channelTencentIMRoomSender,
|
||
NoticeType: noticeTypeApplicationCreated,
|
||
TargetUserID: application.Requester.UserID,
|
||
GroupID: application.RoomID,
|
||
PayloadJSON: mustJSON(requesterRoomPayload),
|
||
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 applicationAcceptedDeliveries(appCode, message, application, payload), 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 applicationAcceptedDeliveries(appCode string, message usermq.UserOutboxMessage, application cpApplication, payload map[string]any) []CPDelivery {
|
||
// 同意成功后双方都要在 C2C 会话里看到一条消息;notice_delivery_events 的幂等键只有 event_id + channel,
|
||
// 所以第二条 C2C 使用独立内部 channel,但 publishDelivery 仍按腾讯 IM 单聊投递。
|
||
requesterPayload := cloneJSONMap(payload)
|
||
requesterPayload["c2c_notice_role"] = "accepted_to_requester"
|
||
requesterPayload["message_text"] = "我同意了你"
|
||
acceptorPayload := cloneJSONMap(payload)
|
||
acceptorPayload["c2c_notice_role"] = "accepted_to_acceptor"
|
||
acceptorPayload["message_text"] = "对方已同意你的申请"
|
||
deliveries := []CPDelivery{
|
||
{
|
||
AppCode: appCode,
|
||
EventID: message.EventID,
|
||
EventType: message.EventType,
|
||
Channel: channelTencentIMC2C,
|
||
NoticeType: noticeTypeApplicationAccepted,
|
||
SenderUserID: application.Target.UserID,
|
||
TargetUserID: application.Requester.UserID,
|
||
PayloadJSON: mustJSON(requesterPayload),
|
||
CreatedAtMS: message.OccurredAtMS,
|
||
},
|
||
{
|
||
AppCode: appCode,
|
||
EventID: message.EventID,
|
||
EventType: message.EventType,
|
||
Channel: channelTencentIMC2CAcceptor,
|
||
NoticeType: noticeTypeApplicationAccepted,
|
||
SenderUserID: application.Requester.UserID,
|
||
TargetUserID: application.Target.UserID,
|
||
PayloadJSON: mustJSON(acceptorPayload),
|
||
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: noticeTypeApplicationAccepted,
|
||
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
|
||
}
|