package cprelation import ( "context" "database/sql" "fmt" "slices" "strconv" "strings" "time" ) const ( cpListDefaultPageSize = 50 cpListMaxPageSize = 100 dayMS = int64(24 * 60 * 60 * 1000) ) var ( relationshipStatuses = []string{"active", "ended"} applicationStatuses = []string{"pending", "accepted", "rejected", "expired", "blocked"} ) type cpListOptions struct { Page int PageSize int Status string RelationType string } type cpUserDTO struct { UserID int64 `json:"userId,string"` DisplayUserID string `json:"displayUserId"` Username string `json:"username"` Avatar string `json:"avatar"` } type cpGiftSnapshotDTO struct { GiftID string `json:"giftId"` GiftName string `json:"giftName"` GiftIconURL string `json:"giftIconUrl"` GiftAnimationURL string `json:"giftAnimationUrl"` GiftCount int64 `json:"giftCount"` GiftValue int64 `json:"giftValue"` BillingReceiptID string `json:"billingReceiptId"` } type cpRelationshipDTO struct { RelationshipID string `json:"relationshipId"` RelationType string `json:"relationType"` Status string `json:"status"` UserA cpUserDTO `json:"userA"` UserB cpUserDTO `json:"userB"` IntimacyValue int64 `json:"intimacyValue"` Level int32 `json:"level"` DurationDays int64 `json:"durationDays"` Gift *cpGiftSnapshotDTO `json:"gift,omitempty"` FormedAtMS int64 `json:"formedAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` EndedAtMS int64 `json:"endedAtMs"` } type cpRelationshipPageDTO struct { Items []cpRelationshipDTO `json:"items"` Page int `json:"page"` PageSize int `json:"pageSize"` Total int64 `json:"total"` ServerTimeMS int64 `json:"serverTimeMs"` } type cpApplicationDTO struct { ApplicationID string `json:"applicationId"` RelationType string `json:"relationType"` Status string `json:"status"` Requester cpUserDTO `json:"requester"` Target cpUserDTO `json:"target"` Gift cpGiftSnapshotDTO `json:"gift"` RoomID string `json:"roomId"` RoomRegionID int64 `json:"roomRegionId"` CreatedAtMS int64 `json:"createdAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"` ExpiresAtMS int64 `json:"expiresAtMs"` DecidedAtMS int64 `json:"decidedAtMs"` } type cpApplicationPageDTO struct { Items []cpApplicationDTO `json:"items"` Page int `json:"page"` PageSize int `json:"pageSize"` Total int64 `json:"total"` ServerTimeMS int64 `json:"serverTimeMs"` } // ListRelationships 给管理端按全站维度分页读取关系;这里不能复用 App 侧按单用户读取的仓储,否则会漏掉“所有用户关系”这个运营视角。 func (s *Service) ListRelationships(ctx context.Context, appCode string, options cpListOptions) (cpRelationshipPageDTO, error) { options = normalizeCPListOptions(options) nowMS := time.Now().UTC().UnixMilli() whereSQL, args, err := relationshipListWhere(appCode, options) if err != nil { return cpRelationshipPageDTO{}, err } if s == nil || s.db == nil { return cpRelationshipPageDTO{}, errorsUserDBNotConfigured() } total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_relationships r WHERE "+whereSQL, args...) if err != nil { return cpRelationshipPageDTO{}, err } queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options)) rows, err := s.db.QueryContext(ctx, ` SELECT r.relationship_id, r.relation_type, r.status, r.user_a_id, COALESCE(user_a.current_display_user_id, ''), COALESCE(user_a.username, ''), COALESCE(user_a.avatar, ''), r.user_b_id, COALESCE(user_b.current_display_user_id, ''), COALESCE(user_b.username, ''), COALESCE(user_b.avatar, ''), r.intimacy_value, r.level_no, r.formed_at_ms, r.updated_at_ms, r.ended_at_ms, COALESCE(source.gift_id, ''), COALESCE(source.gift_name, ''), COALESCE(source.gift_icon_url, ''), COALESCE(source.gift_animation_url, ''), COALESCE(source.gift_count, 0), COALESCE(source.gift_value, 0), COALESCE(source.billing_receipt_id, '') FROM user_cp_relationships r LEFT JOIN users user_a ON user_a.app_code = r.app_code AND user_a.user_id = r.user_a_id LEFT JOIN users user_b ON user_b.app_code = r.app_code AND user_b.user_id = r.user_b_id LEFT JOIN user_cp_applications source ON source.app_code = r.app_code AND source.application_id = r.source_application_id WHERE `+whereSQL+` ORDER BY r.formed_at_ms DESC, r.relationship_id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return cpRelationshipPageDTO{}, err } defer rows.Close() items, err := scanRelationshipRows(rows, nowMS) if err != nil { return cpRelationshipPageDTO{}, err } return cpRelationshipPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil } // ListApplications 读取所有用户的申请记录;读取前先沿用 App 侧懒过期语义,避免后台把已过期申请继续展示为 pending。 func (s *Service) ListApplications(ctx context.Context, appCode string, options cpListOptions) (cpApplicationPageDTO, error) { options = normalizeCPListOptions(options) nowMS := time.Now().UTC().UnixMilli() whereSQL, args, err := applicationListWhere(appCode, options) if err != nil { return cpApplicationPageDTO{}, err } if s == nil || s.db == nil { return cpApplicationPageDTO{}, errorsUserDBNotConfigured() } if err := s.expirePendingApplications(ctx, appCode, nowMS); err != nil { return cpApplicationPageDTO{}, err } total, err := s.countRows(ctx, "SELECT COUNT(*) FROM user_cp_applications a WHERE "+whereSQL, args...) if err != nil { return cpApplicationPageDTO{}, err } queryArgs := append(append([]any{}, args...), options.PageSize, cpListOffset(options)) rows, err := s.db.QueryContext(ctx, ` SELECT a.application_id, a.relation_type, a.status, a.requester_user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''), a.target_user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''), 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.room_id, a.room_region_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms FROM user_cp_applications a LEFT JOIN users requester ON requester.app_code = a.app_code AND requester.user_id = a.requester_user_id LEFT JOIN users target ON target.app_code = a.app_code AND target.user_id = a.target_user_id WHERE `+whereSQL+` ORDER BY a.created_at_ms DESC, a.application_id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return cpApplicationPageDTO{}, err } defer rows.Close() items, err := scanApplicationRows(rows) if err != nil { return cpApplicationPageDTO{}, err } return cpApplicationPageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total, ServerTimeMS: nowMS}, nil } func (s *Service) expirePendingApplications(ctx context.Context, appCode string, nowMS int64) error { _, err := s.db.ExecContext(ctx, ` UPDATE user_cp_applications SET status = 'expired', updated_at_ms = ? WHERE app_code = ? AND status = 'pending' AND expires_at_ms > 0 AND expires_at_ms <= ?`, nowMS, appCode, nowMS, ) return err } func scanRelationshipRows(rows *sql.Rows, nowMS int64) ([]cpRelationshipDTO, error) { items := []cpRelationshipDTO{} for rows.Next() { var item cpRelationshipDTO var gift cpGiftSnapshotDTO if err := rows.Scan( &item.RelationshipID, &item.RelationType, &item.Status, &item.UserA.UserID, &item.UserA.DisplayUserID, &item.UserA.Username, &item.UserA.Avatar, &item.UserB.UserID, &item.UserB.DisplayUserID, &item.UserB.Username, &item.UserB.Avatar, &item.IntimacyValue, &item.Level, &item.FormedAtMS, &item.UpdatedAtMS, &item.EndedAtMS, &gift.GiftID, &gift.GiftName, &gift.GiftIconURL, &gift.GiftAnimationURL, &gift.GiftCount, &gift.GiftValue, &gift.BillingReceiptID, ); err != nil { return nil, err } item.UserA = normalizeCPUser(item.UserA) item.UserB = normalizeCPUser(item.UserB) item.DurationDays = relationshipDurationDays(item.FormedAtMS, item.EndedAtMS, nowMS) if !gift.empty() { item.Gift = &gift } items = append(items, item) } return items, rows.Err() } func scanApplicationRows(rows *sql.Rows) ([]cpApplicationDTO, error) { items := []cpApplicationDTO{} for rows.Next() { var item cpApplicationDTO if err := rows.Scan( &item.ApplicationID, &item.RelationType, &item.Status, &item.Requester.UserID, &item.Requester.DisplayUserID, &item.Requester.Username, &item.Requester.Avatar, &item.Target.UserID, &item.Target.DisplayUserID, &item.Target.Username, &item.Target.Avatar, &item.Gift.GiftID, &item.Gift.GiftName, &item.Gift.GiftIconURL, &item.Gift.GiftAnimationURL, &item.Gift.GiftCount, &item.Gift.GiftValue, &item.Gift.BillingReceiptID, &item.RoomID, &item.RoomRegionID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.ExpiresAtMS, &item.DecidedAtMS, ); err != nil { return nil, err } item.Requester = normalizeCPUser(item.Requester) item.Target = normalizeCPUser(item.Target) items = append(items, item) } return items, rows.Err() } func relationshipListWhere(appCode string, options cpListOptions) (string, []any, error) { where := []string{"r.app_code = ?"} args := []any{appCode} if err := appendRelationFilter(&where, &args, "r", options.RelationType); err != nil { return "", nil, err } if err := appendStatusFilter(&where, &args, "r", options.Status, relationshipStatuses, "关系状态不正确"); err != nil { return "", nil, err } return strings.Join(where, " AND "), args, nil } func applicationListWhere(appCode string, options cpListOptions) (string, []any, error) { where := []string{"a.app_code = ?"} args := []any{appCode} if err := appendRelationFilter(&where, &args, "a", options.RelationType); err != nil { return "", nil, err } if err := appendStatusFilter(&where, &args, "a", options.Status, applicationStatuses, "申请状态不正确"); err != nil { return "", nil, err } return strings.Join(where, " AND "), args, nil } func appendRelationFilter(where *[]string, args *[]any, alias string, value string) error { relationType := normalizeCPFilterValue(value) if relationType == "" { return nil } if !slices.Contains(relationTypes, relationType) { return fmt.Errorf("%w: 关系类型不正确", ErrInvalidArgument) } *where = append(*where, alias+".relation_type = ?") *args = append(*args, relationType) return nil } func appendStatusFilter(where *[]string, args *[]any, alias string, value string, allowed []string, message string) error { status := normalizeCPFilterValue(value) if status == "" { return nil } if !slices.Contains(allowed, status) { return fmt.Errorf("%w: %s", ErrInvalidArgument, message) } *where = append(*where, alias+".status = ?") *args = append(*args, status) return nil } func normalizeCPListOptions(options cpListOptions) cpListOptions { if options.Page < 1 { options.Page = 1 } if options.PageSize < 1 { options.PageSize = cpListDefaultPageSize } if options.PageSize > cpListMaxPageSize { options.PageSize = cpListMaxPageSize } options.Status = normalizeCPFilterValue(options.Status) options.RelationType = normalizeCPFilterValue(options.RelationType) return options } func normalizeCPFilterValue(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "all" { return "" } return value } func cpListOffset(options cpListOptions) int { return (options.Page - 1) * options.PageSize } func (s *Service) countRows(ctx context.Context, query string, args ...any) (int64, error) { var total int64 if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { return 0, err } return total, nil } func normalizeCPUser(user cpUserDTO) cpUserDTO { if strings.TrimSpace(user.DisplayUserID) == "" && user.UserID > 0 { user.DisplayUserID = strconv.FormatInt(user.UserID, 10) } return user } func relationshipDurationDays(formedAtMS int64, endedAtMS int64, nowMS int64) int64 { if formedAtMS <= 0 { return 0 } endAtMS := nowMS if endedAtMS > 0 { endAtMS = endedAtMS } if endAtMS < formedAtMS { return 0 } // 运营侧“天数”按自然关系天展示:刚建立也算第 1 天,避免表格出现 0 天这种难以理解的状态。 return ((endAtMS - formedAtMS) / dayMS) + 1 } func (gift cpGiftSnapshotDTO) empty() bool { return strings.TrimSpace(gift.GiftID) == "" && strings.TrimSpace(gift.GiftName) == "" && strings.TrimSpace(gift.GiftIconURL) == "" && strings.TrimSpace(gift.GiftAnimationURL) == "" && strings.TrimSpace(gift.BillingReceiptID) == "" && gift.GiftCount == 0 && gift.GiftValue == 0 } func errorsUserDBNotConfigured() error { return fmt.Errorf("user db is not configured") }