package hostsalarysettlement import ( "context" "database/sql" "fmt" "strconv" "strings" ) const maxPageSize = 100 type Service struct { walletDB *sql.DB userDB *sql.DB } type userProfile struct { UserID int64 DisplayUserID string Username string Avatar string } func NewService(walletDB *sql.DB, userDB *sql.DB) *Service { return &Service{walletDB: walletDB, userDB: userDB} } func (s *Service) List(ctx context.Context, appCode string, req query) ([]settlementDTO, int64, error) { if s == nil || s.walletDB == nil { return nil, 0, fmt.Errorf("wallet mysql is not configured") } req = normalizeQuery(req) whereSQL, args := settlementWhere(appCode, req) var total int64 if err := s.walletDB.QueryRowContext(ctx, `SELECT COUNT(*) FROM host_salary_settlement_records `+whereSQL, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := s.walletDB.QueryContext(ctx, ` SELECT settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, cycle_key, policy_id, level_no, total_diamonds, host_salary_usd_minor_delta, host_coin_reward_delta, agency_salary_usd_minor_delta, residual_usd_minor_delta, status, reason, created_at_ms FROM host_salary_settlement_records `+whereSQL+` ORDER BY created_at_ms DESC, settlement_id DESC LIMIT ? OFFSET ?`, append(args, req.PageSize, offset(req.Page, req.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() items := make([]settlementDTO, 0, req.PageSize) userIDs := make([]int64, 0, req.PageSize*2) for rows.Next() { var item settlementDTO var userID int64 var agencyOwnerUserID int64 if err := rows.Scan( &item.SettlementID, &item.CommandID, &item.TransactionID, &item.SettlementType, &userID, &agencyOwnerUserID, &item.CycleKey, &item.PolicyID, &item.LevelNo, &item.TotalDiamonds, &item.HostSalaryUSDMinorDelta, &item.HostCoinRewardDelta, &item.AgencySalaryUSDMinorDelta, &item.ResidualUSDMinorDelta, &item.Status, &item.Reason, &item.CreatedAtMS, ); err != nil { return nil, 0, err } item.UserID = strconv.FormatInt(userID, 10) item.AgencyOwnerUserID = formatOptionalUserID(agencyOwnerUserID) item.User = userDTO{UserID: item.UserID} item.AgencyOwner = userDTO{UserID: item.AgencyOwnerUserID} item.HostSalaryUSDDelta = formatUSDMinor(item.HostSalaryUSDMinorDelta) item.AgencySalaryUSDDelta = formatUSDMinor(item.AgencySalaryUSDMinorDelta) item.ResidualUSDDelta = formatUSDMinor(item.ResidualUSDMinorDelta) items = append(items, item) if userID > 0 { userIDs = append(userIDs, userID) } if agencyOwnerUserID > 0 { userIDs = append(userIDs, agencyOwnerUserID) } } 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 i := range items { if userID, err := strconv.ParseInt(items[i].UserID, 10, 64); err == nil { items[i].User = userDTOFromProfile(items[i].UserID, profiles[userID]) } if agencyID, err := strconv.ParseInt(items[i].AgencyOwnerUserID, 10, 64); err == nil && agencyID > 0 { items[i].AgencyOwner = userDTOFromProfile(items[i].AgencyOwnerUserID, profiles[agencyID]) } } return items, total, nil } func settlementWhere(appCode string, req query) (string, []any) { conditions := []string{"app_code = ?"} args := []any{strings.TrimSpace(appCode)} if req.CycleKey != "" { conditions = append(conditions, "cycle_key = ?") args = append(args, req.CycleKey) } if req.SettlementType != "" { conditions = append(conditions, "settlement_type = ?") args = append(args, req.SettlementType) } if req.Status != "" { conditions = append(conditions, "status = ?") args = append(args, req.Status) } if req.UserID > 0 { conditions = append(conditions, "user_id = ?") args = append(args, req.UserID) } if req.AgencyOwnerUserID > 0 { conditions = append(conditions, "agency_owner_user_id = ?") args = append(args, req.AgencyOwnerUserID) } if req.PolicyID > 0 { conditions = append(conditions, "policy_id = ?") args = append(args, req.PolicyID) } if req.StartAtMS > 0 { conditions = append(conditions, "created_at_ms >= ?") args = append(args, req.StartAtMS) } if req.EndAtMS > 0 { conditions = append(conditions, "created_at_ms < ?") args = append(args, req.EndAtMS) } // 查询只读 wallet-service 结算事实表,不回推钱包分录;账务对账需要单独走 entries/transactions 明细。 return "WHERE " + strings.Join(conditions, " AND "), args } func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) { if s == nil || s.userDB == nil || len(userIDs) == 0 { return map[int64]userProfile{}, nil } uniqueIDs := uniquePositiveUserIDs(userIDs) if len(uniqueIDs) == 0 { return map[int64]userProfile{}, nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",") args := make([]any, 0, len(uniqueIDs)+1) args = append(args, strings.TrimSpace(appCode)) for _, id := range uniqueIDs { args = append(args, id) } rows, err := s.userDB.QueryContext(ctx, ` SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') FROM users WHERE app_code = ? AND user_id IN (`+placeholders+`)`, args..., ) if err != nil { return nil, err } defer rows.Close() profiles := make(map[int64]userProfile, len(uniqueIDs)) for rows.Next() { var item userProfile if err := rows.Scan(&item.UserID, &item.DisplayUserID, &item.Username, &item.Avatar); err != nil { return nil, err } profiles[item.UserID] = item } return profiles, rows.Err() } func normalizeQuery(req query) query { req.CycleKey = strings.TrimSpace(req.CycleKey) req.SettlementType = normalizeSettlementType(req.SettlementType) req.Status = normalizeStatus(req.Status) if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 50 } if req.PageSize > maxPageSize { req.PageSize = maxPageSize } return req } func normalizeSettlementType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return "" case "daily", "day": return "daily" case "half_month", "half-month", "halfmonth": return "half_month" case "month_end", "month-end", "monthend": return "month_end" default: return "" } } func normalizeStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "all": return "" case "succeeded", "skipped", "failed": return strings.ToLower(strings.TrimSpace(value)) default: return "" } } func uniquePositiveUserIDs(ids []int64) []int64 { seen := map[int64]struct{}{} out := make([]int64, 0, len(ids)) for _, id := range ids { if id <= 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out } func userDTOFromProfile(fallbackID string, profile userProfile) userDTO { if profile.UserID <= 0 { return userDTO{UserID: fallbackID} } return userDTO{ UserID: strconv.FormatInt(profile.UserID, 10), DisplayUserID: profile.DisplayUserID, Username: profile.Username, Avatar: profile.Avatar, } } func formatOptionalUserID(value int64) string { if value <= 0 { return "" } return strconv.FormatInt(value, 10) } func formatUSDMinor(value int64) string { sign := "" if value < 0 { sign = "-" value = -value } whole := value / 100 fraction := value % 100 if fraction == 0 { return sign + strconv.FormatInt(whole, 10) } if fraction < 10 { return sign + strconv.FormatInt(whole, 10) + ".0" + strconv.FormatInt(fraction, 10) } return sign + strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10) } func offset(page int, pageSize int) int { if page <= 1 { return 0 } return (page - 1) * pageSize }