package giftrecord import ( "context" "database/sql" "encoding/json" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/modules/shared" ) const ( bizTypeGiftDebit = "gift_debit" bizTypeDirectGiftDebit = "direct_gift_debit" walletStatusSucceeded = "succeeded" ) type Service struct { userDB *sql.DB walletDB *sql.DB } type giftMetadata struct { GiftID string `json:"gift_id"` GiftName string `json:"gift_name"` GiftIconURL string `json:"gift_icon_url"` GiftCount int32 `json:"gift_count"` CoinPrice int64 `json:"coin_price"` ChargeAmount int64 `json:"charge_amount"` CoinSpent int64 `json:"coin_spent"` SenderUserID int64 `json:"sender_user_id"` TargetUserID int64 `json:"target_user_id"` RoomID string `json:"room_id"` DirectGift bool `json:"direct_gift"` } type userProfile struct { UserID int64 DisplayUserID string DefaultDisplayUserID string PrettyDisplayUserID string PrettyID string Username string Avatar string } func NewService(userDB *sql.DB, walletDB *sql.DB) *Service { return &Service{userDB: userDB, walletDB: walletDB} } // List 只读取 wallet-service 已成功落账的交易快照:名称、封面、数量和价格都来自送礼发生时的 metadata, // 不回查当前礼物配置覆盖历史事实,也不扫描 room_outbox 这类会按保留策略清理的投递表。 func (s *Service) List(ctx context.Context, appCode string, query listQuery) ([]recordDTO, int64, error) { query = normalizeListQuery(query) if s == nil || s.walletDB == nil { return nil, 0, fmt.Errorf("wallet mysql is not configured") } senderIDs, senderFiltered, err := s.resolveSenderFilter(ctx, appCode, query.SenderFilter) if err != nil { return nil, 0, err } if senderFiltered && len(senderIDs) == 0 { // 用户身份筛选没有命中时直接返回空页,避免在钱包大表上构造永远为假的扫描条件。 return []recordDTO{}, 0, nil } whereSQL, args := giftRecordWhere(appCode, query, senderIDs) var total int64 if err := s.walletDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM wallet_transactions "+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err } // created_at_ms + transaction_id 组成稳定倒序;对应 owner 库组合索引,避免同一毫秒多笔交易跨页重复或遗漏。 rows, err := s.walletDB.QueryContext(ctx, ` SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}'), created_at_ms FROM wallet_transactions `+whereSQL+` ORDER BY created_at_ms DESC, transaction_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, listOffset(query))...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]recordDTO, 0, query.PageSize) userIDs := make([]int64, 0, query.PageSize*2) for rows.Next() { var item recordDTO var bizType string var metadataJSON string if err := rows.Scan(&item.TransactionID, &bizType, &metadataJSON, &item.CreatedAtMS); err != nil { return nil, 0, err } var metadata giftMetadata if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { return nil, 0, fmt.Errorf("decode gift transaction %s metadata: %w", item.TransactionID, err) } item = recordFromMetadata(item, bizType, metadata) items = append(items, item) userIDs = append(userIDs, metadata.SenderUserID, metadata.TargetUserID) } if err := rows.Err(); err != nil { return nil, 0, err } profiles, err := s.userProfiles(ctx, appCode, userIDs) if err != nil { return nil, 0, err } for index := range items { senderID, _ := strconv.ParseInt(items[index].Sender.UserID, 10, 64) receiverID, _ := strconv.ParseInt(items[index].Receiver.UserID, 10, 64) // 用户资料只补充公共用户组件所需展示字段;即使用户资料缺失,也保留账务快照中的长 ID,不吞掉送礼事实。 if profile, ok := profiles[senderID]; ok { items[index].Sender = userDTOFromProfile(profile) } if profile, ok := profiles[receiverID]; ok { items[index].Receiver = userDTOFromProfile(profile) } } return items, total, nil } func giftRecordWhere(appCode string, query listQuery, senderIDs []int64) (string, []any) { where := "WHERE app_code = ? AND status = ?" args := []any{strings.TrimSpace(appCode), walletStatusSucceeded} switch query.Scene { case sceneRoom: where += " AND biz_type = ?" args = append(args, bizTypeGiftDebit) case sceneDirect: where += " AND biz_type = ?" args = append(args, bizTypeDirectGiftDebit) default: where += " AND biz_type IN (?, ?)" args = append(args, bizTypeGiftDebit, bizTypeDirectGiftDebit) } if query.StartAtMS > 0 { where += " AND created_at_ms >= ?" args = append(args, query.StartAtMS) } if query.EndAtMS > 0 { where += " AND created_at_ms < ?" args = append(args, query.EndAtMS) } if len(senderIDs) > 0 { // gift_sender_user_id 是 wallet owner 从不可变 metadata 快照生成的索引列;后台不能为筛选跨库 JOIN 用户表, // 因此先在 user DB 解析公共身份字段,再用内部 user_id 命中钱包组合索引完成分页。 where += " AND gift_sender_user_id IN (" + placeholders(len(senderIDs)) + ")" for _, senderID := range senderIDs { args = append(args, senderID) } } return where, args } func (s *Service) resolveSenderFilter(ctx context.Context, appCode string, filter shared.UserIdentityFilter) ([]int64, bool, error) { if filter.IsEmpty() { return nil, false, nil } directUserIDs := make([]int64, 0, 1) if userID, err := strconv.ParseInt(strings.TrimSpace(filter.UserID), 10, 64); err == nil && userID > 0 { // 钱包事实必须在用户资料已删除或未同步时仍可按内部长 ID 检索。 directUserIDs = append(directUserIDs, userID) } if s == nil || s.userDB == nil { if len(directUserIDs) > 0 { return directUserIDs, true, nil } return nil, true, fmt.Errorf("user mysql is not configured") } matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UTC().UnixMilli()) args := append([]any{strings.TrimSpace(appCode)}, matchArgs...) // 公共用户筛选允许昵称模糊匹配;最多解析 1000 个内部 ID,既覆盖正常运营检索,又限制后续钱包 IN 条件规模。 rows, err := s.userDB.QueryContext(ctx, ` SELECT u.user_id FROM users u WHERE u.app_code = ? AND `+matchSQL+` ORDER BY u.updated_at_ms DESC, u.user_id DESC LIMIT 1000`, args...) if err != nil { return nil, true, err } defer rows.Close() userIDs := append([]int64(nil), directUserIDs...) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return nil, true, err } userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return nil, true, err } return uniquePositiveInt64s(userIDs), true, nil } func recordFromMetadata(item recordDTO, bizType string, metadata giftMetadata) recordDTO { item.Scene = sceneRoom if bizType == bizTypeDirectGiftDebit || metadata.DirectGift { item.Scene = sceneDirect } item.RoomID = strings.TrimSpace(metadata.RoomID) item.Sender = userDTO{UserID: formatUserID(metadata.SenderUserID)} item.Receiver = userDTO{UserID: formatUserID(metadata.TargetUserID)} item.Gift = giftDTO{ GiftID: strings.TrimSpace(metadata.GiftID), Name: firstNonEmpty(metadata.GiftName, metadata.GiftID, "-"), CoverURL: strings.TrimSpace(metadata.GiftIconURL), } item.GiftCount = metadata.GiftCount item.UnitValue = metadata.CoinPrice item.GiftValue = metadata.CoinSpent if item.GiftValue <= 0 { item.GiftValue = metadata.ChargeAmount } if item.UnitValue <= 0 && item.GiftCount > 0 { item.UnitValue = item.GiftValue / int64(item.GiftCount) } return item } func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { result := make(map[int64]userProfile) if s == nil || s.userDB == nil { return result, nil } userIDs = uniquePositiveInt64s(userIDs) if len(userIDs) == 0 { return result, nil } nowMS := time.Now().UTC().UnixMilli() args := []any{nowMS, nowMS, strings.TrimSpace(appCode)} for _, userID := range userIDs { args = append(args, userID) } // 每页最多查询 2 * page_size 个用户;靓号子查询均命中 (app_code,user_id,status) 索引, // 这样公共用户组件能展示短 ID/靓号,又不会把用户表 join 进大体量钱包分页查询。 rows, err := s.userDB.QueryContext(ctx, ` SELECT users.user_id, users.current_display_user_id, COALESCE(users.default_display_user_id, ''), COALESCE(( SELECT lease.display_user_id FROM pretty_display_user_id_leases lease WHERE lease.app_code = users.app_code AND lease.user_id = users.user_id AND lease.status = 'active' AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 ), ''), COALESCE(( SELECT pretty.pretty_id FROM pretty_display_user_id_leases lease JOIN pretty_display_ids pretty ON pretty.app_code = lease.app_code AND pretty.assigned_lease_id = lease.lease_id AND pretty.status = 'assigned' WHERE lease.app_code = users.app_code AND lease.user_id = users.user_id AND lease.status = 'active' AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?) ORDER BY lease.created_at_ms DESC, lease.lease_id DESC LIMIT 1 ), ''), COALESCE(users.username, ''), COALESCE(users.avatar, '') FROM users WHERE users.app_code = ? AND users.user_id IN (`+placeholders(len(userIDs))+`) `, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var profile userProfile if err := rows.Scan( &profile.UserID, &profile.DisplayUserID, &profile.DefaultDisplayUserID, &profile.PrettyDisplayUserID, &profile.PrettyID, &profile.Username, &profile.Avatar, ); err != nil { return nil, err } result[profile.UserID] = profile } return result, rows.Err() } func userDTOFromProfile(profile userProfile) userDTO { return userDTO{ UserID: formatUserID(profile.UserID), DisplayUserID: profile.DisplayUserID, DefaultDisplayUserID: profile.DefaultDisplayUserID, PrettyDisplayUserID: profile.PrettyDisplayUserID, PrettyID: profile.PrettyID, Username: profile.Username, Avatar: profile.Avatar, } } func uniquePositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } return result } func placeholders(count int) string { if count <= 0 { return "" } return strings.TrimRight(strings.Repeat("?,", count), ",") } func formatUserID(userID int64) string { if userID <= 0 { return "" } return strconv.FormatInt(userID, 10) } func firstNonEmpty(values ...string) string { for _, value := range values { if normalized := strings.TrimSpace(value); normalized != "" { return normalized } } return "" }