// Package cp 持久化 CP/兄弟/姐妹申请、单 active 关系、亲密值和等级默认规则。 package cp import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" cpdomain "hyapp/services/user-service/internal/domain/cp" ) // Repository 是 CP 关系在 user-service MySQL 上的 owner 存储。 type Repository struct { db *sql.DB } // New 基于共享 MySQL 连接池创建 CP 关系存储。 func New(db *sql.DB) *Repository { return &Repository{db: db} } // ListApplications 按用户和方向读取申请,同时懒过期已超时的 pending 申请。 func (r *Repository) ListApplications(ctx context.Context, userID int64, direction string, status string, page int32, pageSize int32, nowMs int64) ([]cpdomain.Application, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) // 列表读取前先做一次轻量懒过期,避免客户端看到已经超过 24 小时但仍可操作的 pending 申请。 if _, err := r.db.ExecContext(ctx, ` UPDATE user_cp_applications SET status = ?, updated_at_ms = ? WHERE app_code = ? AND status = ? AND expires_at_ms <= ?`, cpdomain.ApplicationStatusExpired, nowMs, appCode, cpdomain.ApplicationStatusPending, nowMs, ); err != nil { return nil, 0, err } where := []string{"a.app_code = ?"} args := []any{appCode} // direction 只影响 requester/target 过滤,不把双向申请合并,客户端才能区分我发出的和我收到的申请。 switch strings.ToLower(strings.TrimSpace(direction)) { case "outgoing": where = append(where, "a.requester_user_id = ?") args = append(args, userID) default: where = append(where, "a.target_user_id = ?") args = append(args, userID) } if status = strings.ToLower(strings.TrimSpace(status)); status != "" { where = append(where, "a.status = ?") args = append(args, status) } total, err := countRows(ctx, r.db, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+strings.Join(where, " AND "), args...) if err != nil { return nil, 0, err } args = append(args, clampPageSize(pageSize), offset(page, pageSize)) rows, err := r.db.QueryContext(ctx, applicationSelectSQL("WHERE "+strings.Join(where, " AND ")+` ORDER BY a.created_at_ms DESC, a.application_id DESC LIMIT ? OFFSET ?`), args...) if err != nil { return nil, 0, err } defer rows.Close() applications, err := scanApplications(rows) if err != nil { return nil, 0, err } return applications, total, nil } // AcceptApplication 处理同意申请,事务内保证同一对用户只能形成一个 active 关系,并按关系类型容量限制个人数量。 func (r *Repository) AcceptApplication(ctx context.Context, userID int64, applicationID string, nowMs int64) (cpdomain.Application, cpdomain.Relationship, error) { if r == nil || r.db == nil { return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } // 先锁申请行,再判断状态和过期时间,保证两个端同时点同意/拒绝时只有一个事务能改状态。 application, err := lockApplicationTx(ctx, tx, appCode, applicationID) if err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } if application.Target.UserID != userID { return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.PermissionDenied, "only target user can accept cp application") } if err := assertPendingApplication(application, nowMs); err != nil { if isExpiredPendingApplication(application, nowMs) { // 过期申请虽然不能继续同意,但 expired 本身是一个需要持久化的业务事实; // 这里必须在返回冲突前提交事务,否则 defer Rollback 会把状态收敛一起回滚掉。 if markErr := markApplicationExpiredTx(ctx, tx, appCode, applicationID, nowMs); markErr != nil { return cpdomain.Application{}, cpdomain.Relationship{}, markErr } if commitErr := tx.Commit(); commitErr != nil { return cpdomain.Application{}, cpdomain.Relationship{}, commitErr } } return cpdomain.Application{}, cpdomain.Relationship{}, err } userAID, userBID := orderedPair(application.Requester.UserID, application.Target.UserID) // A/B 关系按无序 pair 加锁;不论 CP、兄弟还是姐妹,同一时间只能存在一种 active 关系。 existing, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID) if err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } if found { return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("%s用户和你已经有%s关系", application.Requester.Username, existing.RelationType)) } // 容量校验按关系类型读取后台配置;CP 固定只能有一个,兄弟/姐妹允许按运营配置扩展。 if err := assertRelationCapacityTx(ctx, tx, appCode, application.RelationType, application.Requester.UserID, application.Target.UserID); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } relationshipID := relationshipIDForPair(userAID, userBID) if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_relationships ( app_code, relationship_id, user_a_id, user_b_id, relation_type, status, intimacy_value, level_no, source_application_id, formed_at_ms, ended_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, 0, ?, ?)`, appCode, relationshipID, userAID, userBID, application.RelationType, cpdomain.RelationshipStatusActive, application.ApplicationID, nowMs, nowMs, nowMs, ); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } // 同意一种关系后,同一对用户之间其他类型的 pending 申请不再允许接受,状态置为 blocked 便于客户端提示。 if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET status = ?, decided_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ?`, cpdomain.ApplicationStatusAccepted, nowMs, nowMs, appCode, application.ApplicationID, ); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET status = ?, updated_at_ms = ? WHERE app_code = ? AND status = ? AND requester_user_id IN (?, ?) AND target_user_id IN (?, ?) AND application_id <> ?`, cpdomain.ApplicationStatusBlocked, nowMs, appCode, cpdomain.ApplicationStatusPending, application.Requester.UserID, application.Target.UserID, application.Requester.UserID, application.Target.UserID, application.ApplicationID, ); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } application.Status = cpdomain.ApplicationStatusAccepted application.UpdatedAtMS = nowMs application.DecidedAtMS = nowMs relationship, err := queryRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID) if err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } // user_outbox 只记录事实;IM、区域广播和后续 push 由下游 notice/统计等消费者按事件类型各自投递。 if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationAccepted, "cp_application", application.Target.UserID, application, nowMs); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } relationshipPayload := map[string]any{"application": application, "relationship": relationship} if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipCreated, "cp_relationship", application.Target.UserID, relationshipPayload, nowMs); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } return application, relationship, tx.Commit() } // RejectApplication 处理拒绝申请;拒绝只改变该申请,不影响同一对用户其他 pending 类型。 func (r *Repository) RejectApplication(ctx context.Context, userID int64, applicationID string, reason string, nowMs int64) (cpdomain.Application, error) { if r == nil || r.db == nil { return cpdomain.Application{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.Application{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) application, err := lockApplicationTx(ctx, tx, appCode, applicationID) if err != nil { return cpdomain.Application{}, err } if application.Target.UserID != userID { return cpdomain.Application{}, xerr.New(xerr.PermissionDenied, "only target user can reject cp application") } if err := assertPendingApplication(application, nowMs); err != nil { if isExpiredPendingApplication(application, nowMs) { // 拒绝接口同样承担惰性过期收敛;过期事实要先提交,再把“不能操作”的冲突返回给客户端。 if markErr := markApplicationExpiredTx(ctx, tx, appCode, applicationID, nowMs); markErr != nil { return cpdomain.Application{}, markErr } if commitErr := tx.Commit(); commitErr != nil { return cpdomain.Application{}, commitErr } } return cpdomain.Application{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET status = ?, decided_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ?`, cpdomain.ApplicationStatusRejected, nowMs, nowMs, appCode, application.ApplicationID, ); err != nil { return cpdomain.Application{}, err } application.Status = cpdomain.ApplicationStatusRejected application.UpdatedAtMS = nowMs application.DecidedAtMS = nowMs payload := map[string]any{"application": application, "reason": strings.TrimSpace(reason)} if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationRejected, "cp_application", application.Target.UserID, payload, nowMs); err != nil { return cpdomain.Application{}, err } return application, tx.Commit() } // ListRelationships 读取用户 active 关系列表;CP 最多一条,兄弟/姐妹可能多条,因此分页必须保留。 func (r *Repository) ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) where := []string{"r.app_code = ?", "r.status = ?", "(r.user_a_id = ? OR r.user_b_id = ?)"} args := []any{appCode, cpdomain.RelationshipStatusActive, userID, userID} if relationType = normalizeRelationType(relationType); relationType != "" { where = append(where, "r.relation_type = ?") args = append(args, relationType) } // total 只统计 active 关系;历史关系不会占用当前关系数量,也不会在 App 资料卡展示。 total, err := countRows(ctx, r.db, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+strings.Join(where, " AND "), args...) if err != nil { return nil, 0, err } // relationshipSelectSQL 的前两个占位是 viewer/me 和 partner 选择,suffix 的过滤参数必须跟在它们后面。 queryArgs := []any{userID, userID} queryArgs = append(queryArgs, args...) queryArgs = append(queryArgs, clampPageSize(pageSize), offset(page, pageSize)) rows, err := r.db.QueryContext(ctx, relationshipSelectSQL("WHERE "+strings.Join(where, " AND ")+` ORDER BY r.formed_at_ms DESC, r.relationship_id DESC LIMIT ? OFFSET ?`), queryArgs...) if err != nil { return nil, 0, err } defer rows.Close() relationships, err := scanRelationships(rows) if err != nil { return nil, 0, err } return relationships, total, nil } // ConsumeGiftEvent 把房间送礼事实转换为 CP 申请或亲密值增长。 func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error) { if r == nil || r.db == nil { 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 { return cpdomain.ConsumeResult{Consumed: false}, nil } if strings.TrimSpace(event.EventID) == "" { return cpdomain.ConsumeResult{}, xerr.New(xerr.InvalidArgument, "room event id is required") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.ConsumeResult{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) if event.AppCode != "" { appCode = appcode.Normalize(event.AppCode) } if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil { return cpdomain.ConsumeResult{}, err } // room outbox event_id 是消费幂等键;重复投递时直接提交空事务,避免重复创建申请或重复加亲密值。 if exists, err := lockConsumedEventTx(ctx, tx, appCode, event.EventID); err != nil { return cpdomain.ConsumeResult{}, err } else if exists { return cpdomain.ConsumeResult{Consumed: false}, tx.Commit() } userAID, userBID := orderedPair(event.SenderUserID, event.TargetUserID) // 已有 active 关系时,任何关系礼物都只增加这对关系的亲密值,不再生成新的申请。 if relationship, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID); err != nil { return cpdomain.ConsumeResult{}, err } else if found { updated, err := addRelationshipIntimacyTx(ctx, tx, appCode, relationship, event.GiftValue, event.TargetUserID, nowMs) if err != nil { return cpdomain.ConsumeResult{}, err } if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "relationship_intimacy_added", "", updated.RelationshipID, "", nowMs); err != nil { return cpdomain.ConsumeResult{}, err } if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipIntimacyChanged, "cp_relationship", event.TargetUserID, map[string]any{"relationship": updated, "gift_event": event}, nowMs); err != nil { return cpdomain.ConsumeResult{}, err } return cpdomain.ConsumeResult{Consumed: true, Relationship: updated}, 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 { 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 } if err := insertConsumptionTx(ctx, tx, appCode, event.EventID, "application_created", application.ApplicationID, "", "", nowMs); err != nil { return cpdomain.ConsumeResult{}, err } if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeApplicationCreated, "cp_application", application.Target.UserID, application, nowMs); err != nil { return cpdomain.ConsumeResult{}, err } return cpdomain.ConsumeResult{Consumed: true, Application: application}, tx.Commit() } func ensureDefaultConfigTx(ctx context.Context, tx *sql.Tx, appCode string, nowMs int64) error { // 默认值只兜底本地和新 App 启动;CP 固定 1 个,兄弟/姐妹默认 5 个并允许后台覆盖。 for _, relationType := range []string{cpdomain.RelationTypeCP, cpdomain.RelationTypeBrother, cpdomain.RelationTypeSister} { maxCount := int32(1) if relationType == cpdomain.RelationTypeBrother || relationType == cpdomain.RelationTypeSister { maxCount = 5 } if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_relation_configs ( app_code, relation_type, max_count_per_user, application_expire_hours, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 24, 'active', ?, ?) ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`, appCode, relationType, maxCount, nowMs, nowMs, ); err != nil { return err } for level := int32(1); level <= 5; level++ { threshold := int64(level-1) * 10000 if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_level_rules ( app_code, relation_type, level_no, intimacy_threshold, reward_resource_group_id, level_icon_url, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 0, '', 'active', ?, ?) ON DUPLICATE KEY UPDATE level_no = VALUES(level_no)`, appCode, relationType, level, threshold, nowMs, nowMs, ); err != nil { return err } } } return nil } func applicationSelectSQL(suffix string) string { // 申请查询统一 join 双方 users 快照,避免 service 层为每条申请再发起批量用户查询。 return ` SELECT a.application_id, a.relation_type, a.status, a.room_id, a.room_region_id, a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, a.gift_count, a.gift_value, a.billing_receipt_id, a.source_room_event_id, a.source_command_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms, requester.user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''), target.user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, '') FROM user_cp_applications a JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id ` + suffix } func scanApplications(rows *sql.Rows) ([]cpdomain.Application, error) { applications := make([]cpdomain.Application, 0) for rows.Next() { application, err := scanApplication(rows) if err != nil { return nil, err } applications = append(applications, application) } return applications, rows.Err() } func scanApplication(scanner interface{ Scan(dest ...any) error }) (cpdomain.Application, error) { var app cpdomain.Application err := scanner.Scan( &app.ApplicationID, &app.RelationType, &app.Status, &app.RoomID, &app.RoomRegionID, &app.Gift.GiftID, &app.Gift.GiftName, &app.Gift.GiftIconURL, &app.Gift.GiftAnimationURL, &app.Gift.GiftCount, &app.Gift.GiftValue, &app.Gift.BillingReceiptID, &app.SourceEventID, &app.SourceCommandID, &app.CreatedAtMS, &app.UpdatedAtMS, &app.ExpiresAtMS, &app.DecidedAtMS, &app.Requester.UserID, &app.Requester.DisplayUserID, &app.Requester.Username, &app.Requester.Avatar, &app.Target.UserID, &app.Target.DisplayUserID, &app.Target.Username, &app.Target.Avatar, ) return app, err } func lockApplicationTx(ctx context.Context, tx *sql.Tx, appCode string, applicationID string) (cpdomain.Application, error) { row := tx.QueryRowContext(ctx, applicationSelectSQL(` WHERE a.app_code = ? AND a.application_id = ? FOR UPDATE`), appCode, strings.TrimSpace(applicationID)) application, err := scanApplication(row) if err == sql.ErrNoRows { return cpdomain.Application{}, xerr.New(xerr.NotFound, "cp application not found") } return application, err } func markApplicationExpiredTx(ctx context.Context, tx *sql.Tx, appCode string, applicationID string, nowMs int64) error { _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET status = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ? AND status = ?`, cpdomain.ApplicationStatusExpired, nowMs, appCode, applicationID, cpdomain.ApplicationStatusPending, ) return err } func isExpiredPendingApplication(application cpdomain.Application, nowMs int64) bool { // 只有 pending 且超过过期时间的申请才做惰性状态收敛;accepted/rejected/blocked 等终态不能被误改成 expired。 return application.Status == cpdomain.ApplicationStatusPending && application.ExpiresAtMS <= nowMs } func assertPendingApplication(application cpdomain.Application, nowMs int64) error { if application.Status != cpdomain.ApplicationStatusPending { return xerr.New(xerr.Conflict, "cp application is not pending") } if application.ExpiresAtMS <= nowMs { return xerr.New(xerr.Conflict, "cp application expired") } return nil } func relationshipSelectSQL(suffix string) string { // viewerUserID 连续出现两次:me 投影和 partner 选择都必须以当前查看用户为准。 return ` SELECT r.relationship_id, r.relation_type, r.status, r.user_a_id, r.user_b_id, r.intimacy_value, r.level_no, r.source_application_id, r.formed_at_ms, r.updated_at_ms, me.user_id, COALESCE(me.current_display_user_id, ''), COALESCE(me.username, ''), COALESCE(me.avatar, ''), partner.user_id, COALESCE(partner.current_display_user_id, ''), COALESCE(partner.username, ''), COALESCE(partner.avatar, '') FROM user_cp_relationships r JOIN users me ON me.app_code = r.app_code AND me.user_id = ? JOIN users partner ON partner.app_code = r.app_code AND partner.user_id = CASE WHEN r.user_a_id = ? THEN r.user_b_id ELSE r.user_a_id END ` + suffix } func scanRelationships(rows *sql.Rows) ([]cpdomain.Relationship, error) { relationships := make([]cpdomain.Relationship, 0) for rows.Next() { relationship, err := scanRelationship(rows) if err != nil { return nil, err } relationships = append(relationships, relationship) } return relationships, rows.Err() } func scanRelationship(scanner interface{ Scan(dest ...any) error }) (cpdomain.Relationship, error) { var relationship cpdomain.Relationship err := scanner.Scan( &relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID, &relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.UpdatedAtMS, &relationship.Me.UserID, &relationship.Me.DisplayUserID, &relationship.Me.Username, &relationship.Me.Avatar, &relationship.Partner.UserID, &relationship.Partner.DisplayUserID, &relationship.Partner.Username, &relationship.Partner.Avatar, ) return relationship, err } func queryRelationshipByIDTx(ctx context.Context, tx *sql.Tx, appCode string, viewerUserID int64, relationshipID string) (cpdomain.Relationship, error) { row := tx.QueryRowContext(ctx, relationshipSelectSQL(` WHERE r.app_code = ? AND r.relationship_id = ?`), viewerUserID, viewerUserID, appCode, strings.TrimSpace(relationshipID), ) relationship, err := scanRelationship(row) if err == sql.ErrNoRows { return cpdomain.Relationship{}, xerr.New(xerr.NotFound, "cp relationship not found") } return relationship, err } func lockActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, userAID int64, userBID int64) (cpdomain.Relationship, bool, error) { // active 关系行用 FOR UPDATE 锁住 pair,关系创建和送礼加亲密值都走这把锁。 row := tx.QueryRowContext(ctx, ` SELECT relationship_id, relation_type, status, user_a_id, user_b_id, intimacy_value, level_no, source_application_id, formed_at_ms, updated_at_ms FROM user_cp_relationships WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND status = ? FOR UPDATE`, appCode, userAID, userBID, cpdomain.RelationshipStatusActive, ) var relationship cpdomain.Relationship err := row.Scan( &relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID, &relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.UpdatedAtMS, ) if err == sql.ErrNoRows { return cpdomain.Relationship{}, false, nil } return relationship, true, err } func ensurePairHasNoActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, userAID int64, userBID int64, viewerUserID int64) error { if relationship, found, err := lockActiveRelationshipTx(ctx, tx, appCode, userAID, userBID); err != nil { return err } else if found { partnerID := userAID if partnerID == viewerUserID { partnerID = userBID } return xerr.New(xerr.Conflict, fmt.Sprintf("%d用户和你已经有%s关系", partnerID, relationship.RelationType)) } return nil } func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64) error { if normalizeRelationType(relationType) == "" { return xerr.New(xerr.Conflict, "cp relation type is disabled") } maxCount, err := relationMaxCountTx(ctx, tx, appCode, relationType) if err != nil { return err } for _, userID := range []int64{userAID, userBID} { // 容量按用户和关系类型计算;同一对用户互斥由 pair 锁保证,不同对象可以同时拥有兄弟/姐妹关系。 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, ) if err != nil { return err } if total >= int64(maxCount) { return xerr.New(xerr.Conflict, "user cp relation count reaches limit") } } return nil } func relationMaxCountTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string) (int32, error) { // CP 的“一人只能有一个”是产品硬约束,不能被后台或脏数据配置放宽。 if normalizeRelationType(relationType) == cpdomain.RelationTypeCP { return 1, nil } var maxCount int32 err := tx.QueryRowContext(ctx, ` SELECT max_count_per_user FROM user_cp_relation_configs WHERE app_code = ? AND relation_type = ? AND status = 'active' FOR UPDATE`, appCode, relationType).Scan(&maxCount) if err == sql.ErrNoRows { return 0, xerr.New(xerr.Conflict, "cp relation type is disabled") } if maxCount <= 0 { maxCount = 1 } return maxCount, err } func addRelationshipIntimacyTx(ctx context.Context, tx *sql.Tx, appCode string, relationship cpdomain.Relationship, delta int64, viewerUserID int64, nowMs int64) (cpdomain.Relationship, error) { if delta < 0 { delta = 0 } nextValue := relationship.IntimacyValue + delta // 等级直接由当前亲密值重算,避免漏处理一次大额礼物跨多级升级。 nextLevel, err := levelForIntimacyTx(ctx, tx, appCode, relationship.RelationType, nextValue) if err != nil { return cpdomain.Relationship{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_relationships SET intimacy_value = ?, level_no = ?, updated_at_ms = ? WHERE app_code = ? AND relationship_id = ?`, nextValue, nextLevel, nowMs, appCode, relationship.RelationshipID, ); err != nil { return cpdomain.Relationship{}, err } return queryRelationshipByIDTx(ctx, tx, appCode, viewerUserID, relationship.RelationshipID) } func levelForIntimacyTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, intimacy int64) (int32, error) { rows, err := tx.QueryContext(ctx, ` SELECT level_no, intimacy_threshold FROM user_cp_level_rules WHERE app_code = ? AND relation_type = ? AND status = 'active' ORDER BY level_no ASC`, appCode, relationType) if err != nil { return 1, err } defer rows.Close() level := int32(1) for rows.Next() { var candidate int32 var threshold int64 if err := rows.Scan(&candidate, &threshold); err != nil { return 1, err } if intimacy >= threshold { level = candidate } } return level, rows.Err() } func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string, event cpdomain.GiftEvent, relationType string, nowMs int64) (cpdomain.Application, error) { expireHours, err := applicationExpireHoursTx(ctx, tx, appCode, relationType) if err != nil { return cpdomain.Application{}, err } expiresAtMS := nowMs + int64(expireHours)*time.Hour.Milliseconds() row := tx.QueryRowContext(ctx, applicationSelectSQL(` WHERE a.app_code = ? AND a.requester_user_id = ? AND a.target_user_id = ? AND a.relation_type = ? AND a.status = ? AND a.expires_at_ms > ? ORDER BY a.created_at_ms DESC LIMIT 1 FOR UPDATE`), appCode, event.SenderUserID, event.TargetUserID, relationType, cpdomain.ApplicationStatusPending, nowMs, ) application, err := scanApplication(row) if err != nil && err != sql.ErrNoRows { return cpdomain.Application{}, err } if err == nil { // 同一对用户、同一类型已有 pending 申请时刷新礼物快照和过期时间,不生成多条待处理卡片。 if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET room_id = ?, room_region_id = ?, gift_id = ?, gift_name = ?, gift_icon_url = ?, gift_animation_url = ?, gift_count = ?, gift_value = ?, billing_receipt_id = ?, source_room_event_id = ?, source_command_id = ?, expires_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND application_id = ?`, event.RoomID, event.VisibleRegionID, event.GiftID, event.GiftName, event.GiftIconURL, event.GiftAnimationURL, event.GiftCount, event.GiftValue, event.BillingReceiptID, event.EventID, event.CommandID, expiresAtMS, nowMs, appCode, application.ApplicationID, ); err != nil { return cpdomain.Application{}, err } application.RoomID = event.RoomID application.RoomRegionID = event.VisibleRegionID application.Gift = giftSnapshotFromEvent(event) application.SourceEventID = event.EventID application.SourceCommandID = event.CommandID application.ExpiresAtMS = expiresAtMS application.UpdatedAtMS = nowMs return application, nil } applicationID := fmt.Sprintf("cp_app:%s", strings.TrimSpace(event.EventID)) // 新申请 ID 派生自 room outbox event_id,结合消费位点可以保证 MQ 重投不会生成第二条申请。 if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_applications ( app_code, application_id, requester_user_id, target_user_id, relation_type, status, room_id, room_region_id, gift_id, gift_name, gift_icon_url, gift_animation_url, gift_count, gift_value, billing_receipt_id, source_room_event_id, source_command_id, created_at_ms, updated_at_ms, expires_at_ms, decided_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`, appCode, applicationID, event.SenderUserID, event.TargetUserID, relationType, cpdomain.ApplicationStatusPending, event.RoomID, event.VisibleRegionID, event.GiftID, event.GiftName, event.GiftIconURL, event.GiftAnimationURL, event.GiftCount, event.GiftValue, event.BillingReceiptID, event.EventID, event.CommandID, nowMs, nowMs, expiresAtMS, ); err != nil { return cpdomain.Application{}, err } row = tx.QueryRowContext(ctx, applicationSelectSQL(` WHERE a.app_code = ? AND a.application_id = ?`), appCode, applicationID) return scanApplication(row) } func applicationExpireHoursTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string) (int32, error) { var hours int32 err := tx.QueryRowContext(ctx, ` SELECT application_expire_hours FROM user_cp_relation_configs WHERE app_code = ? AND relation_type = ? AND status = 'active'`, appCode, relationType).Scan(&hours) if err == sql.ErrNoRows { return 0, xerr.New(xerr.Conflict, "cp relation type is disabled") } if hours <= 0 { hours = 24 } return hours, err } func lockConsumedEventTx(ctx context.Context, tx *sql.Tx, appCode string, eventID string) (bool, error) { // 消费位点也加锁,防止同一 event_id 在并行 consumer 中同时进入创建/加亲密值分支。 var found string err := tx.QueryRowContext(ctx, ` SELECT room_event_id FROM user_cp_gift_event_consumption WHERE app_code = ? AND room_event_id = ? FOR UPDATE`, appCode, eventID).Scan(&found) if err == sql.ErrNoRows { return false, nil } return err == nil, err } func insertConsumptionTx(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, applicationID string, relationshipID string, skipReason string, nowMs int64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_gift_event_consumption ( app_code, room_event_id, status, application_id, relationship_id, skip_reason, consumed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, eventID, status, applicationID, relationshipID, skipReason, nowMs, nowMs, nowMs, ) return err } func insertOutboxTx(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, payload any, nowMs int64) error { payloadBytes, err := json.Marshal(payload) if err != nil { return err } eventID := fmt.Sprintf("%s:%s:%d:%d", aggregateType, eventType, aggregateID, nowMs) // outbox 与业务状态同事务提交;RocketMQ 发布失败只影响异步 IM/统计,不回滚已经同意的关系。 _, err = tx.ExecContext(ctx, ` INSERT INTO user_outbox ( app_code, event_id, event_type, aggregate_type, aggregate_id, status, worker_id, lock_until_ms, retry_count, next_retry_at_ms, last_error, payload_json, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'pending', '', 0, 0, 0, '', CAST(? AS JSON), ?, ?) ON DUPLICATE KEY UPDATE event_id = VALUES(event_id)`, appCode, eventID, eventType, aggregateType, aggregateID, string(payloadBytes), nowMs, nowMs, ) return err } func countRows(ctx context.Context, q interface { QueryRowContext(context.Context, string, ...any) *sql.Row }, query string, args ...any) (int64, error) { var total int64 err := q.QueryRowContext(ctx, query, args...).Scan(&total) return total, err } func normalizeRelationType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case cpdomain.RelationTypeCP: return cpdomain.RelationTypeCP case cpdomain.RelationTypeBrother: return cpdomain.RelationTypeBrother case cpdomain.RelationTypeSister: return cpdomain.RelationTypeSister default: return "" } } func orderedPair(a int64, b int64) (int64, int64) { if a < b { return a, b } return b, a } func relationshipIDForPair(userAID int64, userBID int64) string { // 关系 ID 固定由无序 pair 派生,配合 active 唯一约束保证 A/B 不会同时有多种 active 关系。 return fmt.Sprintf("cp_rel:%d:%d", userAID, userBID) } func giftSnapshotFromEvent(event cpdomain.GiftEvent) cpdomain.GiftSnapshot { return cpdomain.GiftSnapshot{ GiftID: strings.TrimSpace(event.GiftID), GiftName: strings.TrimSpace(event.GiftName), GiftIconURL: strings.TrimSpace(event.GiftIconURL), GiftAnimationURL: strings.TrimSpace(event.GiftAnimationURL), GiftCount: event.GiftCount, GiftValue: event.GiftValue, BillingReceiptID: strings.TrimSpace(event.BillingReceiptID), } } func clampPageSize(pageSize int32) int32 { if pageSize <= 0 { return 20 } if pageSize > 100 { return 100 } return pageSize } func offset(page int32, pageSize int32) int32 { if page <= 1 { return 0 } return (page - 1) * clampPageSize(pageSize) }