2026-06-08 19:00:23 +08:00

869 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
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
` + 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.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 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 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)
}