package coinledger import ( "context" "database/sql" "fmt" "strconv" "strings" ) const ( coinAssetType = "COIN" directionIn = "income" directionOut = "expense" ) type Service struct { userDB *sql.DB walletDB *sql.DB } type userProfile struct { UserID int64 DisplayUserID string Username string Avatar string } func NewService(userDB *sql.DB, walletDB *sql.DB) *Service { return &Service{userDB: userDB, walletDB: walletDB} } func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query listQuery) ([]coinLedgerEntryDTO, int64, error) { query = normalizeListQuery(query) if s == nil || s.walletDB == nil { return nil, 0, fmt.Errorf("wallet mysql is not configured") } userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword) if err != nil { return nil, 0, err } if userFiltered && len(userIDs) == 0 { return []coinLedgerEntryDTO{}, 0, nil } // 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。 whereSQL, args := coinLedgerWhere(appCode, query, userIDs) var total int64 if err := s.walletDB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM wallet_entries e JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id `+whereSQL, args..., ).Scan(&total); err != nil { return nil, 0, err } rows, err := s.walletDB.QueryContext(ctx, ` SELECT e.entry_id, e.transaction_id, e.user_id, wt.biz_type, e.available_delta, e.available_after, e.counterparty_user_id, e.room_id, e.created_at_ms FROM wallet_entries e JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id `+whereSQL+` ORDER BY e.created_at_ms DESC, e.entry_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, offset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() items := make([]coinLedgerEntryDTO, 0, query.PageSize) itemUserIDs := make([]int64, 0, query.PageSize) for rows.Next() { var item coinLedgerEntryDTO var userID int64 var counterpartyUserID int64 if err := rows.Scan( &item.EntryID, &item.TransactionID, &userID, &item.BizType, &item.AvailableDelta, &item.AvailableAfter, &counterpartyUserID, &item.RoomID, &item.CreatedAtMS, ); err != nil { return nil, 0, err } item.UserID = strconv.FormatInt(userID, 10) item.User = coinLedgerUserDTO{UserID: item.UserID} item.CounterpartyUserID = formatOptionalID(counterpartyUserID) item.Direction = directionForDelta(item.AvailableDelta) item.Amount = absInt64(item.AvailableDelta) items = append(items, item) itemUserIDs = append(itemUserIDs, userID) } if err := rows.Err(); err != nil { return nil, 0, err } profiles, err := s.userProfiles(ctx, appCode, itemUserIDs) if err != nil { return nil, 0, err } for i := range items { userID, _ := strconv.ParseInt(items[i].UserID, 10, 64) if profile, ok := profiles[userID]; ok { items[i].User = coinLedgerUserDTO{ UserID: strconv.FormatInt(profile.UserID, 10), DisplayUserID: profile.DisplayUserID, Username: profile.Username, Avatar: profile.Avatar, } } } return items, total, nil } func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string) ([]int64, bool, error) { keyword = strings.TrimSpace(keyword) if keyword == "" { return nil, false, nil } directUserIDs := make([]int64, 0, 1) if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 { directUserIDs = append(directUserIDs, numeric) } if s == nil || s.userDB == nil { if len(directUserIDs) > 0 { return directUserIDs, true, nil } return nil, true, fmt.Errorf("user mysql is not configured") } // 用户列筛选同时支持长 ID、当前短 ID 和昵称;数字关键字直接加入 user_id,避免用户资料缺失时查不到账务事实。 args := []any{appCode} where := "WHERE app_code = ? AND (current_display_user_id = ?" args = append(args, keyword) if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 { where += " OR user_id = ?" args = append(args, numeric) } where += " OR username LIKE ? ESCAPE '\\\\')" args = append(args, "%"+escapeLike(keyword)+"%") rows, err := s.userDB.QueryContext(ctx, ` SELECT user_id FROM users `+where+` ORDER BY updated_at_ms DESC, 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) } return uniqueInt64s(userIDs), true, rows.Err() } func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { result := make(map[int64]userProfile, len(userIDs)) if s == nil || s.userDB == nil || len(userIDs) == 0 { return result, nil } userIDs = uniqueInt64s(userIDs) args := make([]any, 0, len(userIDs)+1) args = append(args, appCode) for _, id := range userIDs { args = append(args, id) } rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') FROM users WHERE app_code = ? AND user_id IN (%s) `, 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.Username, &profile.Avatar); err != nil { return nil, err } result[profile.UserID] = profile } return result, rows.Err() } func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) { where := "WHERE e.app_code = ? AND e.asset_type = ?" args := []any{appCode, coinAssetType} if query.StartAtMS > 0 { where += " AND e.created_at_ms >= ?" args = append(args, query.StartAtMS) } if query.EndAtMS > 0 { where += " AND e.created_at_ms < ?" args = append(args, query.EndAtMS) } if len(userIDs) > 0 { where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(userIDs))) for _, id := range userIDs { args = append(args, id) } } return where, args } func normalizeListQuery(query listQuery) listQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } query.UserKeyword = strings.TrimSpace(query.UserKeyword) return query } func directionForDelta(delta int64) string { if delta < 0 { return directionOut } return directionIn } func absInt64(value int64) int64 { if value < 0 { return -value } return value } func formatOptionalID(value int64) string { if value <= 0 { return "" } return strconv.FormatInt(value, 10) } func offset(page int, pageSize int) int { if page < 1 { page = 1 } return (page - 1) * pageSize } func placeholders(count int) string { if count <= 0 { return "" } return strings.TrimRight(strings.Repeat("?,", count), ",") } func uniqueInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) result := make([]int64, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} result = append(result, value) } return result } func escapeLike(value string) string { value = strings.ReplaceAll(value, `\`, `\\`) value = strings.ReplaceAll(value, `%`, `\%`) value = strings.ReplaceAll(value, `_`, `\_`) return value }