feat(cp): notify when relationship capacity is reached
This commit is contained in:
parent
8a8a222dff
commit
513509c5e6
@ -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` outbox,notice-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 关系已达上限;管理员账号发给 A,payload 带 `message_text=用户关系已经达到上限` |
|
||||
| 无 | 无 | 解除关系成功不发 IM,App 根据解除接口返回刷新 |
|
||||
|
||||
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 投递给送礼人。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 结果;
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user