diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index 8465c3d5..c6c74621 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -758,10 +758,8 @@ func roomGiftCPEventFromRoomMessage(body []byte) (cpdomain.GiftEvent, bool, erro if err := proto.Unmarshal(envelope.GetBody(), &sent); err != nil { return cpdomain.GiftEvent{}, false, err } - // 只有明确标记 cp_relation_type 的礼物才进入关系逻辑;普通礼物仍由其他读模型消费。 - if strings.TrimSpace(sent.GetCpRelationType()) == "" { - return cpdomain.GiftEvent{}, false, nil - } + // CP worker 必须接收所有送礼事实:没有 active 关系时仓储层只让 CP/兄弟/姐妹礼物生成申请; + // 已有 active 关系时,普通、幸运和超级幸运礼物都按 wallet 已结算的 GiftValue 增加亲密值。 return cpdomain.GiftEvent{ AppCode: appcode.Normalize(envelope.GetAppCode()), EventID: envelope.GetEventId(), diff --git a/services/user-service/internal/app/app_test.go b/services/user-service/internal/app/app_test.go new file mode 100644 index 00000000..ae21b35f --- /dev/null +++ b/services/user-service/internal/app/app_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "testing" + + "google.golang.org/protobuf/proto" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" + "hyapp/pkg/roommq" +) + +func TestRoomGiftCPEventFromRoomMessageIncludesNormalGift(t *testing.T) { + body, err := proto.Marshal(&roomeventsv1.RoomGiftSent{ + SenderUserId: 1001, + TargetUserId: 1002, + GiftId: "rose", + GiftCount: 1, + GiftValue: 100, + BillingReceiptId: "receipt-normal", + CommandId: "cmd-normal", + GiftTypeCode: "normal", + GiftName: "Rose", + }) + if err != nil { + t.Fatalf("marshal room gift sent failed: %v", err) + } + encoded, err := roommq.EncodeRoomOutboxMessage(&roomeventsv1.EventEnvelope{ + AppCode: "lalu", + EventId: "room_evt_normal_gift", + EventType: "RoomGiftSent", + RoomId: "room-1", + RoomVersion: 7, + OccurredAtMs: 1700000000000, + Body: body, + }) + if err != nil { + t.Fatalf("encode room outbox message failed: %v", err) + } + + event, ok, err := roomGiftCPEventFromRoomMessage(encoded) + if err != nil { + t.Fatalf("convert room gift event failed: %v", err) + } + if !ok { + t.Fatal("normal gifts must reach CP consumer so active relationships can gain intimacy") + } + if event.CPRelationType != "" || event.GiftTypeCode != "normal" || event.GiftValue != 100 || event.TargetUserID != 1002 { + t.Fatalf("normal gift event mismatch: %+v", event) + } +} diff --git a/services/user-service/internal/storage/mysql/cp/repository.go b/services/user-service/internal/storage/mysql/cp/repository.go index 39be9169..17469a51 100644 --- a/services/user-service/internal/storage/mysql/cp/repository.go +++ b/services/user-service/internal/storage/mysql/cp/repository.go @@ -514,7 +514,7 @@ func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEv return cpdomain.ConsumeResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } relationType := normalizeRelationType(event.CPRelationType) - if relationType == "" || event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.SenderUserID == event.TargetUserID { + if event.SenderUserID <= 0 || event.TargetUserID <= 0 || event.SenderUserID == event.TargetUserID { return cpdomain.ConsumeResult{Consumed: false}, nil } if strings.TrimSpace(event.EventID) == "" { @@ -540,7 +540,7 @@ func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEv return cpdomain.ConsumeResult{Consumed: false}, tx.Commit() } userAID, userBID := orderedPair(event.SenderUserID, event.TargetUserID) - // 已有 active 关系时,任何关系礼物都只增加这对关系的亲密值,不再生成新的申请。 + // 已有 active 关系时,任意礼物都只增加这对关系的亲密值;GiftValue 已由 wallet-service 按普通/幸运/超级幸运比例结算。 if relationship, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID); err != nil { return cpdomain.ConsumeResult{}, err } else if found { @@ -556,6 +556,10 @@ func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEv } return cpdomain.ConsumeResult{Consumed: true, Relationship: updated}, tx.Commit() } + if relationType == "" { + // 普通礼物在双方还没有 active 关系时不能创建申请;这类事件不写消费位点,后续若 MQ 重投仍然是无副作用 no-op。 + return cpdomain.ConsumeResult{Consumed: false}, tx.Commit() + } // 没有 active 关系时,CP/兄弟/姐妹礼物只生成 pending 申请;真正建立关系必须由 B 显式同意。 if err := ensurePairHasNoActiveRelationshipTx(ctx, tx, appCode, userAID, userBID, event.TargetUserID); err != nil { if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "skipped", "", "", "existing_relationship", nowMs); err != nil { diff --git a/services/user-service/internal/storage/mysql/cp/repository_test.go b/services/user-service/internal/storage/mysql/cp/repository_test.go index 4504bf6f..5b04dfa9 100644 --- a/services/user-service/internal/storage/mysql/cp/repository_test.go +++ b/services/user-service/internal/storage/mysql/cp/repository_test.go @@ -176,6 +176,101 @@ func TestListActiveIntimacyLeaderboardEntriesOrdersAndIncludesBothUsers(t *testi } } +func TestConsumeGiftEventAddsIntimacyForNormalGiftWhenRelationshipExists(t *testing.T) { + schema := mysqlschema.New(t) + repo := New(schema.DB) + ctx := appcode.WithContext(context.Background(), appcode.Default) + nowMS := int64(1700000000000) + + seedCPTestUser(t, schema.DB, 4201, "Normal Gift Alice", nowMS) + seedCPTestUser(t, schema.DB, 4202, "Normal Gift Bob", nowMS) + seedCPLevelRules(t, schema.DB, cpdomain.RelationTypeCP, []int64{0, 100, 300}, nowMS) + seedActiveCPRelationship(t, schema.DB, "normal_gift_cp", cpdomain.RelationTypeCP, 4201, 4202, 90, 1, nowMS) + + result, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{ + AppCode: appcode.Default, + EventID: "room_evt_normal_gift_cp", + RoomID: "room-normal", + RoomVersion: 3, + OccurredAtMS: nowMS + 1, + SenderUserID: 4201, + TargetUserID: 4202, + GiftID: "rose", + GiftCount: 1, + GiftValue: 37, + BillingReceiptID: "receipt-normal-gift-cp", + CommandID: "cmd-normal-gift-cp", + GiftTypeCode: "normal", + }, nowMS+2) + if err != nil { + t.Fatalf("consume normal gift for active cp failed: %v", err) + } + if !result.Consumed || result.Relationship.RelationshipID != "normal_gift_cp" { + t.Fatalf("normal gift must update existing relationship: %+v", result) + } + if result.Relationship.IntimacyValue != 127 || result.Relationship.Level != 2 { + t.Fatalf("relationship intimacy/level mismatch: %+v", result.Relationship) + } + + var status string + if err := schema.DB.QueryRowContext(ctx, ` + SELECT status + FROM user_cp_gift_event_consumption + WHERE app_code = ? AND room_event_id = ?`, + appcode.Default, "room_evt_normal_gift_cp", + ).Scan(&status); err != nil { + t.Fatalf("read gift consumption failed: %v", err) + } + if status != "relationship_intimacy_added" { + t.Fatalf("normal gift consumption status mismatch: %s", status) + } +} + +func TestConsumeGiftEventIgnoresNormalGiftWithoutRelationship(t *testing.T) { + schema := mysqlschema.New(t) + repo := New(schema.DB) + ctx := appcode.WithContext(context.Background(), appcode.Default) + nowMS := int64(1700000000000) + + seedCPTestUser(t, schema.DB, 4301, "No CP Alice", nowMS) + seedCPTestUser(t, schema.DB, 4302, "No CP Bob", nowMS) + + result, err := repo.ConsumeGiftEvent(ctx, cpdomain.GiftEvent{ + AppCode: appcode.Default, + EventID: "room_evt_normal_no_cp", + RoomID: "room-normal", + RoomVersion: 3, + OccurredAtMS: nowMS + 1, + SenderUserID: 4301, + TargetUserID: 4302, + GiftID: "rose", + GiftCount: 1, + GiftValue: 37, + BillingReceiptID: "receipt-normal-no-cp", + CommandID: "cmd-normal-no-cp", + GiftTypeCode: "normal", + }, nowMS+2) + if err != nil { + t.Fatalf("consume normal gift without cp failed: %v", err) + } + if result.Consumed { + t.Fatalf("normal gift without active relationship must be ignored: %+v", result) + } + + var applicationCount int64 + if err := schema.DB.QueryRowContext(ctx, ` + SELECT COUNT(*) + FROM user_cp_applications + WHERE app_code = ? AND requester_user_id = ? AND target_user_id = ?`, + appcode.Default, 4301, 4302, + ).Scan(&applicationCount); err != nil { + t.Fatalf("count cp applications failed: %v", err) + } + if applicationCount != 0 { + t.Fatalf("normal gift without relationship must not create application, got %d", applicationCount) + } +} + func TestBreakRelationshipPrepareConfirmIsIdempotent(t *testing.T) { schema := mysqlschema.New(t) repo := New(schema.DB)