feat(cp): notify when relationship capacity is reached

This commit is contained in:
zhx 2026-07-20 14:50:18 +08:00
parent 8a8a222dff
commit 513509c5e6
5 changed files with 177 additions and 35 deletions

View File

@ -89,10 +89,16 @@ sequenceDiagram
R->>R: update heat/rank/command log
R-->>F: SendGiftResponse
R-->>U: RoomGiftSent MQ
U->>U: create or refresh CP application
U-->>N: UserCPApplicationCreated MQ
N->>IM: A -> B C2C cp_application_created
N->>IM: room cp_application_created, recipient_user_id=B
U->>U: check relationship capacity
alt either user has available capacity
U->>U: create or refresh CP application
U-->>N: UserCPApplicationCreated MQ
N->>IM: A -> B C2C cp_application_created
N->>IM: room cp_application_created, recipient_user_id=B
else either user has reached capacity
U-->>N: UserCPRelationshipCapacityReached MQ
N->>IM: system -> A C2C cp_relationship_capacity_reached
end
```
处理规则:
@ -101,7 +107,10 @@ sequenceDiagram
- 同一个 `RoomGiftSent.event_id` 只能消费一次。
- A 给 B 发送同一类型 CP 礼物时,如果已有 pending 申请,刷新礼物快照和 `expires_at_ms`
- A 给 B 发送不同类型 CP 礼物时,可以各有一条 pending 申请。
- 如果任一方已经有 active 关系,申请可以存在,但接受时会被拒绝;已有关系双方互送礼物会增加亲密值。
- A/B 已经是 active 关系时,双方互送任意礼物只增加亲密值,不重复创建申请。
- A/B 尚无 active 关系,但任一方该类型 active 关系数量已达上限时不创建或刷新申请user-service 同事务写入消费位点和 `UserCPRelationshipCapacityReached` outboxnotice-service 再由腾讯 IM 管理员账号通知送礼人 A。
- 容量提示 payload 使用 `event_type=cp_relationship_capacity_reached``recipient_user_id=A``message_text=用户关系已经达到上限`App 按 `event_id` 去重并展示轻提示。
- 送礼扣费和房间表现先于异步容量判断完成;容量已满只阻止创建无效申请,不回滚礼物账务。
## 6. 同意和拒绝
@ -270,6 +279,7 @@ sequenceDiagram
| `cp_application_rejected` | C2C | B 拒绝后B 发给 A |
| `cp_application_rejected` | 房间群 | B 拒绝后发到来源房间payload 带 `recipient_user_id=A` |
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
| `cp_relationship_capacity_reached` | 系统 C2C | A 送关系礼物后A 或 B 的该类型 active 关系已达上限;管理员账号发给 Apayload 带 `message_text=用户关系已经达到上限` |
| 无 | 无 | 解除关系成功不发 IMApp 根据解除接口返回刷新 |
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝为 B同意会发 `B -> A``A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_v2_<region_id>`v1 旧群 `hy_<app_code>_bc_r_<region_id>` 仅保留过渡期双发),本地测试可通过 `group_id_prefix` 加前缀。
@ -296,3 +306,4 @@ C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A拒绝
- active 关系双方互送任意礼物,亲密值按 `heat_value` 增加。
- active 关系解除会扣除后台配置的金币费用,扣费失败不改变关系状态。
- 同意、拒绝、关系成立的 IM 都写入 `notice_delivery_events`,失败可重试。
- 关系容量已满后再次送关系礼物,会写 `relationship_capacity_reached` 消费位点,不生成 pending 申请,并把 `cp_relationship_capacity_reached` 系统 IM 投递给送礼人。

View File

@ -192,7 +192,7 @@ func validateDelivery(delivery CPDelivery) error {
if isTencentIMC2CChannel(delivery.Channel) && delivery.TargetUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "cp c2c delivery target_user_id is required")
}
if isTencentIMC2CChannel(delivery.Channel) && delivery.SenderUserID <= 0 {
if isUserAuthoredTencentIMC2CChannel(delivery.Channel) && delivery.SenderUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "cp c2c delivery sender_user_id is required")
}
if (delivery.Channel == channelTencentIMRoom || delivery.Channel == channelTencentIMRoomSender || delivery.Channel == channelTencentIMRegion) && strings.TrimSpace(delivery.GroupID) == "" {
@ -203,13 +203,18 @@ func validateDelivery(delivery CPDelivery) error {
func isTencentIMC2CChannel(channel string) bool {
switch channel {
case channelTencentIMC2C, channelTencentIMC2CAcceptor:
case channelTencentIMC2C, channelTencentIMC2CAcceptor, channelTencentIMSystemC2C:
return true
default:
return false
}
}
func isUserAuthoredTencentIMC2CChannel(channel string) bool {
// 系统 C2C 必须让 REST client 使用管理员账号;只有关系双方代发的 C2C 才要求 sender_user_id。
return channel == channelTencentIMC2C || channel == channelTencentIMC2CAcceptor
}
func truncateError(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 512 {

View File

@ -17,21 +17,24 @@ import (
)
const (
eventApplicationCreated = "UserCPApplicationCreated"
eventApplicationAccepted = "UserCPApplicationAccepted"
eventApplicationRejected = "UserCPApplicationRejected"
eventRelationshipCreated = "UserCPRelationshipCreated"
eventApplicationCreated = "UserCPApplicationCreated"
eventApplicationAccepted = "UserCPApplicationAccepted"
eventApplicationRejected = "UserCPApplicationRejected"
eventRelationshipCreated = "UserCPRelationshipCreated"
eventRelationshipCapacityReached = "UserCPRelationshipCapacityReached"
channelTencentIMC2C = "tencent_im_c2c"
channelTencentIMC2CAcceptor = "tencent_im_c2c_acceptor"
channelTencentIMSystemC2C = "tencent_im_system_c2c"
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"
noticeTypeApplicationCreated = "cp_application_created"
noticeTypeApplicationAccepted = "cp_application_accepted"
noticeTypeApplicationRejected = "cp_application_rejected"
noticeTypeRelationshipCreated = "cp_relationship_created"
noticeTypeRelationshipCapacityReached = "cp_relationship_capacity_reached"
)
// Repository owns notice_delivery_events for CP IM deliveries; user-service remains the CP fact owner.
@ -116,6 +119,16 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
Ext: "cp_relation_notice",
PayloadJSON: delivery.PayloadJSON,
})
case channelTencentIMSystemC2C:
// FromAccount 留空时腾讯 IM client 会使用配置的管理员账号;容量告警属于系统事实,
// 不能伪装成收礼人发出的 C2C 消息,也不能污染双方的关系会话方向。
err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(delivery.TargetUserID),
EventID: delivery.EventID,
Desc: delivery.NoticeType,
Ext: "system_notice",
PayloadJSON: delivery.PayloadJSON,
})
case channelTencentIMRoom, channelTencentIMRoomSender:
err = s.publisher.PublishGroupCustomMessage(publishCtx, tencentim.CustomGroupMessage{
GroupID: delivery.GroupID,
@ -235,7 +248,7 @@ func (s *Service) markFailed(ctx context.Context, delivery CPDelivery, options C
func isCPNoticeEvent(eventType string) bool {
switch eventType {
case eventApplicationCreated, eventApplicationAccepted, eventApplicationRejected, eventRelationshipCreated:
case eventApplicationCreated, eventApplicationAccepted, eventApplicationRejected, eventRelationshipCreated, eventRelationshipCapacityReached:
return true
default:
return false
@ -333,11 +346,56 @@ func deliveriesFromMessage(message usermq.UserOutboxMessage, groupIDPrefix strin
return nil, err
}
return relationshipCreatedDelivery(appCode, message, application, relationshipID, groupID)
case eventRelationshipCapacityReached:
payload, targetUserID, err := cpRelationshipCapacityReachedPayload(message, root)
if err != nil {
return nil, err
}
return []CPDelivery{{
AppCode: appCode,
EventID: message.EventID,
EventType: message.EventType,
Channel: channelTencentIMSystemC2C,
NoticeType: noticeTypeRelationshipCapacityReached,
TargetUserID: targetUserID,
PayloadJSON: mustJSON(payload),
CreatedAtMS: message.OccurredAtMS,
}}, nil
default:
return nil, nil
}
}
func cpRelationshipCapacityReachedPayload(message usermq.UserOutboxMessage, root map[string]any) (map[string]any, int64, error) {
senderUserID := int64Value(root, "sender_user_id", "SenderUserID")
targetUserID := int64Value(root, "target_user_id", "TargetUserID")
limitedUserID := int64Value(root, "limited_user_id", "LimitedUserID")
relationType := stringValue(root, "relation_type", "RelationType")
messageText := stringValue(root, "message_text", "MessageText")
if senderUserID <= 0 || targetUserID <= 0 || limitedUserID <= 0 || relationType == "" || messageText == "" {
return nil, 0, fmt.Errorf("cp relationship capacity payload is incomplete")
}
// 最终 IM 使用字符串承载 user_id避免 Dart/JavaScript 客户端经过 JSON number 时丢失 BIGINT 精度。
payload := map[string]any{
"event_id": message.EventID,
"event_type": noticeTypeRelationshipCapacityReached,
"source_event_type": message.EventType,
"app_code": appcode.Normalize(message.AppCode),
"relation_type": relationType,
"sender_user_id": strconv.FormatInt(senderUserID, 10),
"target_user_id": strconv.FormatInt(targetUserID, 10),
"limited_user_id": strconv.FormatInt(limitedUserID, 10),
"recipient_user_id": strconv.FormatInt(senderUserID, 10),
"current_count": int64Value(root, "current_count", "CurrentCount"),
"max_count": int64Value(root, "max_count", "MaxCount"),
"room_id": stringValue(root, "room_id", "RoomID"),
"source_room_event_id": stringValue(root, "source_room_event_id", "SourceRoomEventID"),
"message_text": messageText,
"source_created_at_ms": message.OccurredAtMS,
}
return payload, senderUserID, nil
}
func applicationDecisionDeliveries(appCode string, message usermq.UserOutboxMessage, application cpApplication, payload map[string]any, noticeType string) []CPDelivery {
// 同意/拒绝结果必须同步覆盖两个入口:
// 1. A 在私聊里看到 C2C 结果;

View File

@ -25,6 +25,7 @@ const (
EventTypeApplicationAccepted = "UserCPApplicationAccepted"
EventTypeApplicationRejected = "UserCPApplicationRejected"
EventTypeRelationshipCreated = "UserCPRelationshipCreated"
EventTypeRelationshipCapacityReached = "UserCPRelationshipCapacityReached"
EventTypeRelationshipIntimacyChanged = "UserCPRelationshipIntimacyChanged"
EventTypeRelationshipLevelUp = "UserCPRelationshipLevelUp"
EventTypeRelationshipEnded = "UserCPRelationshipEnded"
@ -203,3 +204,17 @@ type ConsumeResult struct {
Application Application
Relationship Relationship
}
// RelationshipCapacityReachedNotice 是关系礼物已扣费、但任一方该类型 active 关系名额已满时的通知事实。
// user-service 只固化判断结果notice-service 再把它转换为发给送礼人的系统 IM。
type RelationshipCapacityReachedNotice struct {
RelationType string `json:"relation_type"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
LimitedUserID int64 `json:"limited_user_id"`
CurrentCount int64 `json:"current_count"`
MaxCount int32 `json:"max_count"`
RoomID string `json:"room_id"`
SourceRoomEventID string `json:"source_room_event_id"`
MessageText string `json:"message_text"`
}

View File

@ -17,6 +17,8 @@ import (
const formationGiftCoinThreshold = int64(5000)
const relationshipCapacityReachedMessage = "用户关系已经达到上限"
// Repository 是 CP 关系在 user-service MySQL 上的 owner 存储。
type Repository struct {
db *sql.DB
@ -712,6 +714,33 @@ func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEv
}
return cpdomain.ConsumeResult{Consumed: true}, tx.Commit()
}
// 关系礼物扣费和房间表现已经在上游完成,这里只能根据 user-service 的关系事实判断申请是否仍有意义。
// 任一方该类型名额已满时不再制造一条注定无法同意的 pending 申请,而是同事务记录消费位点和系统通知 outbox
// notice-service 最终只通知送礼人,避免目标用户收到不可操作的申请卡片。
capacity, err := relationCapacityTx(ctx, tx, appCode, relationType, event.SenderUserID, event.TargetUserID)
if err != nil {
return cpdomain.ConsumeResult{}, err
}
if capacity.LimitedUserID > 0 {
if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "skipped", "", "", "relationship_capacity_reached", event.GiftValue, event.OccurredAtMS, nowMs); err != nil {
return cpdomain.ConsumeResult{}, err
}
notice := cpdomain.RelationshipCapacityReachedNotice{
RelationType: relationType,
SenderUserID: event.SenderUserID,
TargetUserID: event.TargetUserID,
LimitedUserID: capacity.LimitedUserID,
CurrentCount: capacity.CurrentCount,
MaxCount: capacity.MaxCount,
RoomID: event.RoomID,
SourceRoomEventID: event.EventID,
MessageText: relationshipCapacityReachedMessage,
}
if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipCapacityReached, "cp_relationship_capacity", event.SenderUserID, notice, nowMs); err != nil {
return cpdomain.ConsumeResult{}, err
}
return cpdomain.ConsumeResult{Consumed: true}, tx.Commit()
}
application, err := upsertApplicationFromGiftTx(ctx, tx, appCode, event, relationType, nowMs)
if err != nil {
return cpdomain.ConsumeResult{}, err
@ -1085,40 +1114,64 @@ func ensurePairHasNoActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCod
return nil
}
func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64, actorUserID int64) error {
type relationCapacity struct {
LimitedUserID int64
CurrentCount int64
MaxCount int32
}
func relationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64) (relationCapacity, error) {
if normalizeRelationType(relationType) == "" {
return xerr.New(xerr.Conflict, "cp relation type is disabled")
return relationCapacity{}, xerr.New(xerr.Conflict, "cp relation type is disabled")
}
maxCount, err := relationMaxCountTx(ctx, tx, appCode, relationType)
if err != nil {
return err
return relationCapacity{}, err
}
for _, userID := range []int64{userAID, userBID} {
// 容量按用户和关系类型计算;同一对用户互斥由 pair 锁保证,不同对象可以同时拥有兄弟/姐妹关系。
// user_a/user_b 分支分别命中 idx_user_cp_rel_user_a / idx_user_cp_rel_user_b避免 OR 条件退化为大范围扫描。
total, err := countRows(ctx, tx, `
SELECT COUNT(*)
FROM user_cp_relationships
WHERE app_code = ? AND status = ?
AND relation_type = ?
AND (user_a_id = ? OR user_b_id = ?)`,
appCode, cpdomain.RelationshipStatusActive, relationType, userID, userID,
FROM (
SELECT relationship_id
FROM user_cp_relationships FORCE INDEX (idx_user_cp_rel_user_a)
WHERE app_code = ? AND user_a_id = ? AND status = ? AND relation_type = ?
UNION ALL
SELECT relationship_id
FROM user_cp_relationships FORCE INDEX (idx_user_cp_rel_user_b)
WHERE app_code = ? AND user_b_id = ? AND status = ? AND relation_type = ?
) active_relationships`,
appCode, userID, cpdomain.RelationshipStatusActive, relationType,
appCode, userID, cpdomain.RelationshipStatusActive, relationType,
)
if err != nil {
return err
return relationCapacity{}, err
}
if total >= int64(maxCount) {
// CP 固定一人一个;同意按钮的操作者需要知道是自己还是申请另一方已占用名额。
// 兄弟/姐妹由后台配置多名额,继续使用通用容量错误,避免把非 CP 关系误称为 CP。
if normalizeRelationType(relationType) == cpdomain.RelationTypeCP {
if userID == actorUserID {
return xerr.New(xerr.CPAlreadyExistsSelf, "current user already has an active cp relationship")
}
return xerr.New(xerr.CPAlreadyExistsOther, "other party already has an active cp relationship")
}
return xerr.New(xerr.Conflict, "user cp relation count reaches limit")
return relationCapacity{LimitedUserID: userID, CurrentCount: total, MaxCount: maxCount}, nil
}
}
return nil
return relationCapacity{MaxCount: maxCount}, nil
}
func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64, actorUserID int64) error {
capacity, err := relationCapacityTx(ctx, tx, appCode, relationType, userAID, userBID)
if err != nil {
return err
}
if capacity.LimitedUserID <= 0 {
return nil
}
// CP 固定一人一个;同意按钮的操作者需要知道是自己还是申请另一方已占用名额。
// 兄弟/姐妹由后台配置多名额,继续使用通用容量错误,避免把非 CP 关系误称为 CP。
if normalizeRelationType(relationType) == cpdomain.RelationTypeCP {
if capacity.LimitedUserID == actorUserID {
return xerr.New(xerr.CPAlreadyExistsSelf, "current user already has an active cp relationship")
}
return xerr.New(xerr.CPAlreadyExistsOther, "other party already has an active cp relationship")
}
return xerr.New(xerr.Conflict, "user cp relation count reaches limit")
}
func relationMaxCountTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string) (int32, error) {