// Package cp 持久化 CP/兄弟/姐妹申请、单 active 关系、亲密值和等级默认规则。 package cp import ( "context" "database/sql" "encoding/json" "fmt" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "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, ended_by_user_id, breakup_cost_coins, breakup_command_id, breakup_wallet_transaction_id, breakup_coin_balance_after, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, 0, 0, 0, '', '', 0, ?, ?) ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type), status = VALUES(status), intimacy_value = 0, level_no = 1, source_application_id = VALUES(source_application_id), formed_at_ms = VALUES(formed_at_ms), ended_at_ms = 0, ended_by_user_id = 0, breakup_cost_coins = 0, breakup_command_id = '', breakup_wallet_transaction_id = '', breakup_coin_balance_after = 0, updated_at_ms = VALUES(updated_at_ms)`, 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 } // ListActiveIntimacyLeaderboardEntries 读取当前 active 关系亲密值榜单快照;cron 会把结果全量覆盖到 Redis zset。 func (r *Repository) ListActiveIntimacyLeaderboardEntries(ctx context.Context, limit int) ([]cpdomain.IntimacyLeaderboardEntry, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) if limit <= 0 { limit = 1000 } // 榜单只读取 active 关系,ended 关系依赖下一轮 Redis 全量替换自然消失;这里不读取历史关系,避免旧亲密值继续上榜。 // 用户基础资料在同一个 SQL 内 join 出来,保证关系快照和双方短号/昵称/头像来自同一时刻的 MySQL 读视图。 rows, err := r.db.QueryContext(ctx, ` SELECT r.relationship_id, r.relation_type, r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, user_a.user_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''), user_b.user_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, '') FROM user_cp_relationships r JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id WHERE r.app_code = ? AND r.status = ? -- 亲密值是主排序;同分时用 updated_at_ms 和 relationship_id 稳定顺序,Redis 写入会保留这个 tie-break。 ORDER BY r.intimacy_value DESC, r.updated_at_ms DESC, r.relationship_id ASC LIMIT ?`, appCode, cpdomain.RelationshipStatusActive, limit, ) if err != nil { return nil, err } defer rows.Close() entries := make([]cpdomain.IntimacyLeaderboardEntry, 0) for rows.Next() { // scan 层只做字段搬运,不补头像框;头像框来自 wallet-service,避免 user-service 复制资源归属事实。 entry, err := scanIntimacyLeaderboardEntry(rows) if err != nil { return nil, err } entries = append(entries, entry) } return entries, rows.Err() } // ListWeeklyRankEntries 按消费事实聚合一个 UTC 周期内的 CP 关系新增亲密值。 // score 只等于窗口内 relationship_intimacy_added gift_value 之和;关系表 intimacy_value 只服务普通亲密榜和等级累计态,不参与活动周榜排名。 func (r *Repository) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } appCode := appcode.FromContext(ctx) if limit <= 0 { limit = 3 } where := []string{ "c.app_code = ?", "c.status = 'relationship_intimacy_added'", "c.relationship_id <> ''", "c.occurred_at_ms >= ?", "c.occurred_at_ms < ?", "c.gift_value > 0", } args := []any{appCode, startMS, endMS} if relationType = normalizeRelationType(relationType); relationType != "" { where = append(where, "r.relation_type = ?") args = append(args, relationType) } args = append(args, limit, appCode, appCode) // 周榜以礼物消费位点为事实源,relationship 表只提供双方用户、关系类型和展示时间;即使累计亲密值更高,也不能覆盖本周期新增分数排序。 // 同分时用首次得分时间和 relationship_id 稳定排序,保证 cron 重试、后台查询和 H5 展示看到同一批排名。 rows, err := r.db.QueryContext(ctx, ` SELECT ranked.rank_no, ranked.relationship_id, ranked.relation_type, ranked.score, ranked.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''), ranked.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''), ranked.formed_at_ms, ranked.updated_at_ms, ranked.first_scored_at_ms, ranked.last_scored_at_ms FROM ( SELECT ROW_NUMBER() OVER (ORDER BY SUM(c.gift_value) DESC, MIN(c.occurred_at_ms) ASC, c.relationship_id ASC) AS rank_no, c.relationship_id, r.relation_type, SUM(c.gift_value) AS score, r.user_a_id, r.user_b_id, r.formed_at_ms, r.updated_at_ms, MIN(c.occurred_at_ms) AS first_scored_at_ms, MAX(c.occurred_at_ms) AS last_scored_at_ms FROM user_cp_gift_event_consumption c JOIN user_cp_relationships r ON r.app_code = c.app_code AND r.relationship_id = c.relationship_id WHERE `+strings.Join(where, " AND ")+` GROUP BY c.relationship_id, r.relation_type, r.user_a_id, r.user_b_id, r.formed_at_ms, r.updated_at_ms ORDER BY score DESC, first_scored_at_ms ASC, c.relationship_id ASC LIMIT ? ) ranked JOIN users user_a ON user_a.app_code = ? AND user_a.user_id = ranked.user_a_id JOIN users user_b ON user_b.app_code = ? AND user_b.user_id = ranked.user_b_id ORDER BY ranked.rank_no ASC`, args..., ) if err != nil { return nil, err } defer rows.Close() entries := make([]cpdomain.WeeklyRankEntry, 0) for rows.Next() { var entry cpdomain.WeeklyRankEntry if err := rows.Scan( &entry.Rank, &entry.RelationshipID, &entry.RelationType, &entry.Score, &entry.UserA.UserID, &entry.UserA.DisplayUserID, &entry.UserA.Username, &entry.UserA.Avatar, &entry.UserB.UserID, &entry.UserB.DisplayUserID, &entry.UserB.Username, &entry.UserB.Avatar, &entry.FormedAtMS, &entry.UpdatedAtMS, &entry.FirstScoredAtMS, &entry.LastScoredAtMS, ); err != nil { return nil, err } entries = append(entries, entry) } return entries, rows.Err() } // PrepareBreakRelationship 为解除关系创建 pending 占位;占位持有 command 幂等事实,但不提前结束关系。 func (r *Repository) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) { if r == nil || r.db == nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } // 所有解除分支先锁关系行,再锁 breakup 行;prepare/confirm/cancel 共享这个顺序以降低死锁概率。 relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if err := assertRelationshipParticipant(relationship, userID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if existing, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } else if found { if err := assertBreakupMatches(existing, userID, relationshipID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if existing.Status == cpdomain.BreakupStatusCanceled { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled") } return existing, relationship, tx.Commit() } if relationship.Status != cpdomain.RelationshipStatusActive { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active") } if pending, found, err := lockPendingBreakupForRelationshipTx(ctx, tx, appCode, relationshipID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } else if found { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("cp relationship breakup is pending: %s", pending.CommandID)) } cost := relationship.BreakupCostCoins if cost < 0 { cost = 0 } if _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_relationship_breakups ( app_code, command_id, relationship_id, requester_user_id, relation_type, breakup_cost_coins, status, wallet_transaction_id, coin_balance_after, reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, '', 0, '', ?, ?, 0, 0)`, appCode, commandID, relationship.RelationshipID, userID, relationship.RelationType, cost, cpdomain.BreakupStatusPending, nowMs, nowMs, ); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } return breakup, relationship, tx.Commit() } // ConfirmBreakRelationship 在钱包扣费成功后真正结束关系;重复确认同一 command 只返回已确认结果。 func (r *Repository) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) { if r == nil || r.db == nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if err := assertRelationshipParticipant(relationship, userID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if breakup.Status == cpdomain.BreakupStatusConfirmed { return breakup, relationship, tx.Commit() } if breakup.Status == cpdomain.BreakupStatusCanceled { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled") } if relationship.Status != cpdomain.RelationshipStatusActive { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active") } if paidCoinAmount != breakup.BreakupCostCoins { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp breakup paid amount mismatch") } if breakup.BreakupCostCoins > 0 && walletTransactionID == "" { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required") } if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_relationships SET status = ?, ended_at_ms = ?, ended_by_user_id = ?, breakup_cost_coins = ?, breakup_command_id = ?, breakup_wallet_transaction_id = ?, breakup_coin_balance_after = ?, updated_at_ms = ? WHERE app_code = ? AND relationship_id = ? AND status = ?`, cpdomain.RelationshipStatusEnded, nowMs, userID, breakup.BreakupCostCoins, commandID, walletTransactionID, coinBalanceAfter, nowMs, appCode, relationshipID, cpdomain.RelationshipStatusActive, ); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_relationship_breakups SET status = ?, wallet_transaction_id = ?, coin_balance_after = ?, updated_at_ms = ?, confirmed_at_ms = ? WHERE app_code = ? AND command_id = ? AND status = ?`, cpdomain.BreakupStatusConfirmed, walletTransactionID, coinBalanceAfter, nowMs, nowMs, appCode, commandID, cpdomain.BreakupStatusPending, ); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } relationship, err = queryRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID) if err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } payload := map[string]any{"relationship": relationship, "breakup": breakup} if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipEnded, "cp_relationship", userID, payload, nowMs); err != nil { return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err } return breakup, relationship, tx.Commit() } // CancelBreakRelationship 释放 pending 占位;钱包扣费失败后 gateway 调用它,关系仍保持 active。 func (r *Repository) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error) { if r == nil || r.db == nil { return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return cpdomain.RelationshipBreakup{}, err } defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID) if err != nil { return cpdomain.RelationshipBreakup{}, err } if err := assertRelationshipParticipant(relationship, userID); err != nil { return cpdomain.RelationshipBreakup{}, err } breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, err } if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil { return cpdomain.RelationshipBreakup{}, err } if breakup.Status == cpdomain.BreakupStatusConfirmed { return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Conflict, "cp relationship breakup is confirmed") } if breakup.Status == cpdomain.BreakupStatusPending { if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_relationship_breakups SET status = ?, reason = ?, updated_at_ms = ?, canceled_at_ms = ? WHERE app_code = ? AND command_id = ? AND status = ?`, cpdomain.BreakupStatusCanceled, strings.TrimSpace(reason), nowMs, nowMs, appCode, commandID, cpdomain.BreakupStatusPending, ); err != nil { return cpdomain.RelationshipBreakup{}, err } breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, err } } return breakup, tx.Commit() } // 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 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 关系时,任意礼物都只增加这对关系的亲密值;GiftValue 已由 wallet-service 按普通/幸运/超级幸运比例结算。 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, "", event.GiftValue, event.OccurredAtMS, 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() } 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", event.GiftValue, event.OccurredAtMS, 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, "", "", event.GiftValue, event.OccurredAtMS, 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, breakup_cost_coins, status, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 24, 0, '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, r.ended_at_ms, CASE WHEN r.status = '` + cpdomain.RelationshipStatusEnded + `' THEN r.breakup_cost_coins ELSE COALESCE(config.breakup_cost_coins, 0) END AS breakup_cost_coins, COALESCE(( SELECT current_rule.intimacy_threshold FROM user_cp_level_rules current_rule WHERE current_rule.app_code = r.app_code AND current_rule.relation_type = r.relation_type AND current_rule.level_no = r.level_no AND current_rule.status = 'active' LIMIT 1 ), 0) AS current_level_threshold, COALESCE(( SELECT next_rule.intimacy_threshold FROM user_cp_level_rules next_rule WHERE next_rule.app_code = r.app_code AND next_rule.relation_type = r.relation_type AND next_rule.level_no > r.level_no AND next_rule.status = 'active' ORDER BY next_rule.level_no ASC LIMIT 1 ), 0) AS next_level_threshold, 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 LEFT JOIN user_cp_relation_configs config ON config.app_code = r.app_code AND config.relation_type = r.relation_type ` + 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.EndedAtMS, &relationship.BreakupCostCoins, &relationship.CurrentLevelThreshold, &relationship.NextLevelThreshold, &relationship.Me.UserID, &relationship.Me.DisplayUserID, &relationship.Me.Username, &relationship.Me.Avatar, &relationship.Partner.UserID, &relationship.Partner.DisplayUserID, &relationship.Partner.Username, &relationship.Partner.Avatar, ) if err == nil { applyRelationshipLevelProgress(&relationship) } return relationship, err } func scanIntimacyLeaderboardEntry(scanner interface{ Scan(dest ...any) error }) (cpdomain.IntimacyLeaderboardEntry, error) { var entry cpdomain.IntimacyLeaderboardEntry err := scanner.Scan( &entry.RelationshipID, &entry.RelationType, &entry.IntimacyValue, &entry.Level, &entry.FormedAtMS, &entry.UpdatedAtMS, &entry.UserA.UserID, &entry.UserA.DisplayUserID, &entry.UserA.Username, &entry.UserA.Avatar, &entry.UserB.UserID, &entry.UserB.DisplayUserID, &entry.UserB.Username, &entry.UserB.Avatar, ) return entry, err } func applyRelationshipLevelProgress(relationship *cpdomain.Relationship) { if relationship == nil { return } if relationship.CurrentLevelThreshold < 0 { relationship.CurrentLevelThreshold = 0 } if relationship.NextLevelThreshold <= relationship.CurrentLevelThreshold { // 没有下一等级规则时按满级处理;客户端可以直接隐藏“距离下一级”和进度条。 relationship.NextLevelThreshold = 0 relationship.NeededForNextLevel = 0 relationship.LevelProgressPercent = 100 relationship.MaxLevel = true return } remaining := relationship.NextLevelThreshold - relationship.IntimacyValue if remaining < 0 { remaining = 0 } relationship.NeededForNextLevel = remaining span := relationship.NextLevelThreshold - relationship.CurrentLevelThreshold filled := relationship.IntimacyValue - relationship.CurrentLevelThreshold if filled < 0 { filled = 0 } progress := int32((filled * 100) / span) if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } relationship.LevelProgressPercent = progress relationship.MaxLevel = false } 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 lockRelationshipByIDTx(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 = ? FOR UPDATE`), 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 assertRelationshipParticipant(relationship cpdomain.Relationship, userID int64) error { if relationship.UserAID == userID || relationship.UserBID == userID { return nil } return xerr.New(xerr.PermissionDenied, "only relationship user can operate cp relationship") } func lockBreakupByCommandRequiredTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, error) { breakup, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID) if err != nil { return cpdomain.RelationshipBreakup{}, err } if !found { return cpdomain.RelationshipBreakup{}, xerr.New(xerr.NotFound, "cp relationship breakup not found") } return breakup, nil } func lockBreakupByCommandTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, bool, error) { row := tx.QueryRowContext(ctx, breakupSelectSQL(` WHERE app_code = ? AND command_id = ? FOR UPDATE`), appCode, strings.TrimSpace(commandID)) breakup, err := scanBreakup(row) if err == sql.ErrNoRows { return cpdomain.RelationshipBreakup{}, false, nil } return breakup, err == nil, err } func lockPendingBreakupForRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, relationshipID string) (cpdomain.RelationshipBreakup, bool, error) { row := tx.QueryRowContext(ctx, breakupSelectSQL(` WHERE app_code = ? AND relationship_id = ? AND status = ? ORDER BY created_at_ms ASC LIMIT 1 FOR UPDATE`), appCode, strings.TrimSpace(relationshipID), cpdomain.BreakupStatusPending) breakup, err := scanBreakup(row) if err == sql.ErrNoRows { return cpdomain.RelationshipBreakup{}, false, nil } return breakup, err == nil, err } func breakupSelectSQL(suffix string) string { return ` SELECT command_id, relationship_id, requester_user_id, relation_type, breakup_cost_coins, status, wallet_transaction_id, coin_balance_after, reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms FROM user_cp_relationship_breakups ` + suffix } func scanBreakup(scanner interface{ Scan(dest ...any) error }) (cpdomain.RelationshipBreakup, error) { var breakup cpdomain.RelationshipBreakup err := scanner.Scan( &breakup.CommandID, &breakup.RelationshipID, &breakup.RequesterUserID, &breakup.RelationType, &breakup.BreakupCostCoins, &breakup.Status, &breakup.WalletTransactionID, &breakup.CoinBalanceAfter, &breakup.Reason, &breakup.CreatedAtMS, &breakup.UpdatedAtMS, &breakup.ConfirmedAtMS, &breakup.CanceledAtMS, ) return breakup, err } func assertBreakupMatches(breakup cpdomain.RelationshipBreakup, userID int64, relationshipID string) error { if breakup.RequesterUserID != userID || strings.TrimSpace(breakup.RelationshipID) != strings.TrimSpace(relationshipID) { // command_id 是解除动作幂等键,不能被另一个用户或另一个关系复用。 return xerr.New(xerr.Conflict, "cp relationship breakup command conflict") } return nil } 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, ended_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.EndedAtMS, &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, giftValue int64, occurredAtMS int64, nowMs int64) error { if occurredAtMS <= 0 { occurredAtMS = nowMs } _, err := tx.ExecContext(ctx, ` INSERT INTO user_cp_gift_event_consumption ( app_code, room_event_id, status, application_id, relationship_id, skip_reason, gift_value, occurred_at_ms, consumed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, eventID, status, applicationID, relationshipID, skipReason, giftValue, occurredAtMS, 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 } // event_id 是 user_outbox 的投递事实主键,必须按每条通知唯一生成;同一个目标用户同毫秒内可能收到多条 CP 申请/结果通知。 eventID := idgen.New("uout") // 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) }