diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..e884ecd --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,5 @@ +template: testify +force-file-write: true +formatter: goimports +packages: + github.com/CsJsss/CS2Ledger/pkg/platform: {} \ No newline at end of file diff --git a/app.go b/app.go index f122181..82e957f 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/platform" "github.com/CsJsss/CS2Ledger/pkg/service" + "github.com/CsJsss/CS2Ledger/pkg/service/bill" "github.com/CsJsss/CS2Ledger/pkg/service/inventory" "github.com/CsJsss/CS2Ledger/pkg/service/market" "github.com/CsJsss/CS2Ledger/pkg/service/pnl" @@ -189,6 +190,9 @@ func (a *App) GetDashboardSummary() (*DashboardSummary, error) { ds.CompletedTrades += summary.TotalTrades ds.RealizedPl += summary.TotalNetPl } + // Rental income from bill records: 租金 + 续租 - 服务费 + rentalIncome, _ := a.svc.Bill().SumRentalIncome(acc.ID) + ds.TotalRentalIncome += rentalIncome } return ds, nil } @@ -197,6 +201,38 @@ func (a *App) GetRentalHistory(accountID uint, assetID string) ([]model.RentalRe return a.svc.Rental().ListByAsset(accountID, assetID) } +func (a *App) GetBillRecords(accountID uint, page, pageSize int, typeID int, platform string, startTime, endTime int64) (*bill.PaginatedBills, error) { + return a.svc.Bill().List(accountID, page, pageSize, bill.BillFilter{ + TypeID: typeID, + Platform: platform, + StartTime: startTime, + EndTime: endTime, + }) +} + +// DailyBillPoint is pre-aggregated daily bill totals for charts. +type DailyBillPoint struct { + Date string `json:"date"` // "2006-01-02" + TypeID int `json:"typeId"` + ThisMoney int64 `json:"thisMoney"` +} + +// GetBillChartData returns daily-aggregated bill data for chart rendering. +func (a *App) GetBillChartData(accountID uint, startTime, endTime int64) ([]DailyBillPoint, error) { + rows, err := a.svc.Bill().ChartData(accountID, bill.BillFilter{ + StartTime: startTime, + EndTime: endTime, + }) + if err != nil { + return nil, err + } + points := make([]DailyBillPoint, len(rows)) + for i, r := range rows { + points[i] = DailyBillPoint{Date: r.Date, TypeID: r.TypeID, ThisMoney: r.ThisMoney} + } + return points, nil +} + type UserSettings struct { PriceSource string `json:"priceSource"` PriceCacheTTL int `json:"priceCacheTtl"` diff --git a/cmd/platform-cli/cli/billhistory.go b/cmd/platform-cli/cli/billhistory.go new file mode 100644 index 0000000..a7dea0e --- /dev/null +++ b/cmd/platform-cli/cli/billhistory.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/CsJsss/CS2Ledger/pkg/platform" + "github.com/spf13/cobra" +) + +var billhistoryCmd = &cobra.Command{ + Use: "billhistory ", + Short: "Fetch bill / fund flow history from a platform", + Args: cobra.ExactArgs(1), + ValidArgs: validPlatforms, + RunE: runBillHistory, +} + +func init() { + billhistoryCmd.Flags().Int("limit", 10, "Max records to show") + billhistoryCmd.Flags().Int64("since", 0, "Unix millisecond timestamp (0 = all)") + billhistoryCmd.Flags().Bool("raw", false, "Output raw JSON") + billhistoryCmd.Flags().Int("page", 0, "Single page to fetch (0 = all pages)") + billhistoryCmd.Flags().Int("pageSize", 0, "Page size (0 = default 20)") +} + +func runBillHistory(cmd *cobra.Command, args []string) error { + platformName := args[0] + token, err := resolveToken(cmd, platformName) + if err != nil { + return err + } + client, err := createClient(platformName, token) + if err != nil { + return err + } + + limit, _ := cmd.Flags().GetInt("limit") + since, _ := cmd.Flags().GetInt64("since") + raw, _ := cmd.Flags().GetBool("raw") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("pageSize") + + opts := []platform.QueryOption{platform.WithSince(since), platform.WithLimit(limit)} + if page > 0 { + opts = append(opts, platform.WithPage(page)) + } + if pageSize > 0 { + opts = append(opts, platform.WithPageSize(pageSize)) + } + + bills, err := client.GetBillHistory(context.Background(), opts...) + if err != nil { + return err + } + if raw { + printBillsRaw(bills) + } else { + printBillsTable(bills) + } + return nil +} + +func printBillsTable(bills []platform.BillRecord) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "TYPE\tTYPE ID\tMONEY\tORDER NO\tTIME") + for _, b := range bills { + _, _ = fmt.Fprintf(w, "%s\t%d\t%.2f\t%s\t%s\n", + b.TypeName, + b.TypeID, + float64(b.ThisMoney)/100.0, + b.OrderNo, + time.UnixMilli(b.AddTime).Format(time.DateTime), + ) + } + _ = w.Flush() +} diff --git a/cmd/platform-cli/cli/output.go b/cmd/platform-cli/cli/output.go index bc9d50b..84c8ab6 100644 --- a/cmd/platform-cli/cli/output.go +++ b/cmd/platform-cli/cli/output.go @@ -8,6 +8,14 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/platform" ) +func printBillsRaw(bills []platform.BillRecord) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(bills); err != nil { + fmt.Fprintf(os.Stderr, "encode: %v\n", err) + } +} + func printTradesRaw(trades []platform.TradeRecord) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") diff --git a/cmd/platform-cli/cli/root.go b/cmd/platform-cli/cli/root.go index 933b2d2..3adda74 100644 --- a/cmd/platform-cli/cli/root.go +++ b/cmd/platform-cli/cli/root.go @@ -37,6 +37,7 @@ func init() { rootCmd.AddCommand(balanceCmd) rootCmd.AddCommand(buyhistoryCmd) rootCmd.AddCommand(sellhistoryCmd) + rootCmd.AddCommand(billhistoryCmd) } func Execute() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1169955..6b07baa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import InventoryPage from './pages/InventoryPage'; import InventoryDetailPage from './pages/InventoryDetailPage'; import CompletedTradesPage from './pages/CompletedTradesPage'; import PnLPage from './pages/PnLPage'; +import BillPage from './pages/BillPage'; import AccountsPage from './pages/AccountsPage'; import SettingsPage from './pages/SettingsPage'; @@ -19,6 +20,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 42cd095..9635ccb 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -39,6 +39,7 @@ const navItems = [ { to: '/inventory', label: '持仓', icon: }, { to: '/trades/completed', label: '交易记录', icon: }, { to: '/pnl', label: '盈亏', icon: }, + { to: '/bill', label: '资金流水', icon: }, { to: '/accounts', label: '账户管理', icon: }, { to: '/settings', label: '设置', icon: }, ]; diff --git a/frontend/src/hooks/useBillRecords.ts b/frontend/src/hooks/useBillRecords.ts new file mode 100644 index 0000000..58a8077 --- /dev/null +++ b/frontend/src/hooks/useBillRecords.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetBillRecords, GetBillChartData } from '../lib/wails'; + +interface UseBillRecordsOpts { + page?: number; + pageSize?: number; + typeId?: number; + platform?: string; + startTime?: number; + endTime?: number; +} + +export function useBillRecords(accountId: number | null, opts?: UseBillRecordsOpts) { + const page = opts?.page ?? 1; + const pageSize = opts?.pageSize ?? 20; + const typeId = opts?.typeId ?? 0; + const platform = opts?.platform ?? ''; + const startTime = opts?.startTime ?? 0; + const endTime = opts?.endTime ?? 0; + + return useQuery({ + queryKey: ['billRecords', accountId ?? 0, page, pageSize, typeId, platform, startTime, endTime], + queryFn: () => + GetBillRecords(accountId ?? 0, page, pageSize, typeId, platform, startTime, endTime), + staleTime: 2 * 60 * 1000, + placeholderData: (prev) => prev, + }); +} + +/** Pre-aggregated daily bill data for chart rendering. */ +export function useBillChartData(accountId: number | null, startTime?: number, endTime?: number) { + return useQuery({ + queryKey: ['billChartData', accountId ?? 0, startTime ?? 0, endTime ?? 0], + queryFn: () => GetBillChartData(accountId ?? 0, startTime ?? 0, endTime ?? 0), + staleTime: 2 * 60 * 1000, + }); +} diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index 49773a0..8fcf25f 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -14,6 +14,8 @@ import { GetMonthlyBreakdown, GetDashboardSummary, GetRentalHistory, + GetBillRecords, + GetBillChartData, GetMarketPrices, GetSettings, UpdateSettings, @@ -36,6 +38,8 @@ export { GetMonthlyBreakdown, GetDashboardSummary, GetRentalHistory, + GetBillRecords, + GetBillChartData, GetMarketPrices, GetSettings, UpdateSettings, diff --git a/frontend/src/pages/BillPage.tsx b/frontend/src/pages/BillPage.tsx new file mode 100644 index 0000000..9f942e5 --- /dev/null +++ b/frontend/src/pages/BillPage.tsx @@ -0,0 +1,570 @@ +import { useState, useMemo, useEffect } from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Chip from '@mui/material/Chip'; +import FormControl from '@mui/material/FormControl'; +import MenuItem from '@mui/material/MenuItem'; +import Select, { type SelectChangeEvent } from '@mui/material/Select'; +import Skeleton from '@mui/material/Skeleton'; +import TextField from '@mui/material/TextField'; +import TablePagination from '@mui/material/TablePagination'; +import SortableTable from '../components/SortableTable'; +import PageSearchBar from '../components/PageSearchBar'; +import ErrorBanner from '../components/ErrorBanner'; +import EmptyState from '../components/EmptyState'; +import { useBillRecords, useBillChartData } from '../hooks/useBillRecords'; +import { useAccounts } from '../hooks/useAccounts'; +import { useUIStore } from '../store/uiStore'; +import { platformLabel, PLATFORM_OPTIONS } from '../lib/constants'; +import { formatCNY } from '../lib/format'; +import type { model } from '../lib/wails'; +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import * as echarts from 'echarts/core'; +import { BarChart, LineChart } from 'echarts/charts'; +import { + GridComponent, + TooltipComponent, + DataZoomComponent, + LegendComponent, +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; + +echarts.use([ + BarChart, + LineChart, + GridComponent, + TooltipComponent, + DataZoomComponent, + LegendComponent, + CanvasRenderer, +]); + +// Color mapping for internal BillType constants. +// TypeName (platform's original label) is always displayed as the Chip label. +// When TypeID == 99 (BillTypeOther), the platform-specific TypeName is shown as-is. +const TYPE_COLORS: Record = { + 1: 'error', + 2: 'success', + 3: 'success', + 4: 'success', + 5: 'error', + 6: 'info', + 7: 'warning', + 8: 'info', + 9: 'info', + 10: 'success', + 99: 'default', +}; + +const TYPE_LABELS: Record = { + 1: '购买', + 2: '出售', + 3: '收取租金', + 4: '收取续租资金', + 5: '租赁服务费', + 6: '充值', + 7: '提现', + 8: '退款', + 9: '求购账户充值', + 10: '提现退款', + 99: '其他', +}; + +const CHART_COLORS: Record = { + 1: '#d32f2f', + 2: '#2e7d32', + 3: '#1565c0', + 4: '#00897b', + 5: '#e65100', + 6: '#6a1b9a', + 7: '#f9a825', + 8: '#00838f', + 9: '#4e342e', + 99: '#78909c', +}; + +export default function BillPage() { + const selectedAccountId = useUIStore((s) => s.selectedAccountId); + const [searchQuery, setSearchQuery] = useState(''); + const [platformFilter, setPlatformFilter] = useState(''); + const [typeIdFilter, setTypeIdFilter] = useState(''); + const [startDateStr, setStartDateStr] = useState(''); + const [endDateStr, setEndDateStr] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [jumpInput, setJumpInput] = useState(''); + + // Reset page when filters change + useEffect(() => { + setPage(1); + }, [platformFilter, typeIdFilter, startDateStr, endDateStr]); + + // Convert date strings to unix ms for backend filtering + const startTime = useMemo( + () => (startDateStr ? new Date(startDateStr + 'T00:00:00+08:00').getTime() : 0), + [startDateStr], + ); + const endTime = useMemo( + () => (endDateStr ? new Date(endDateStr + 'T23:59:59.999+08:00').getTime() : 0), + [endDateStr], + ); + + const { data, isLoading, error, refetch } = useBillRecords(selectedAccountId, { + page, + pageSize, + typeId: typeIdFilter === '' ? 0 : typeIdFilter, + platform: platformFilter, + startTime, + endTime, + }); + const bills = useMemo(() => data?.records ?? [], [data?.records]); + const totalCount = data?.totalCount ?? 0; + const totalPages = useMemo( + () => Math.max(1, Math.ceil(totalCount / pageSize)), + [totalCount, pageSize], + ); + + // Pre-aggregated daily chart data (not pagination-dependent) + const { data: chartData = [] } = useBillChartData(selectedAccountId, startTime, endTime); + + const { data: accounts = [] } = useAccounts(); + + const accountMap = useMemo(() => { + const m = new Map(); + for (const acc of accounts) m.set(acc.ID, acc.name); + return m; + }, [accounts]); + + const typeFilterOptions = useMemo(() => { + const ids = new Set(chartData.map((b) => b.typeId)); + return Array.from(ids).sort((a, b) => a - b); + }, [chartData]); + + // Table data — already filtered server-side; only search filter applied client-side. + const filteredBills = useMemo(() => { + if (!searchQuery) return bills; + const q = searchQuery.toLowerCase(); + return bills.filter( + (b) => + (b.orderNo ?? '').toLowerCase().includes(q) || (b.typeName ?? '').toLowerCase().includes(q), + ); + }, [bills, searchQuery]); + + // Summary cards — aggregated from pre-aggregated chart data + const typeTotals = useMemo(() => { + const totals: Record = {}; + for (const p of chartData) { + totals[p.typeId] = (totals[p.typeId] ?? 0) + p.thisMoney; + } + return totals; + }, [chartData]); + + const chartOption = useMemo(() => { + if (chartData.length === 0) return null; + + const typeIds = [...new Set(chartData.map((p) => p.typeId))].sort((a, b) => a - b); + + const dayBuckets: Record> = {}; + for (const p of chartData) { + if (!dayBuckets[p.date]) dayBuckets[p.date] = {}; + dayBuckets[p.date][p.typeId] = (dayBuckets[p.date][p.typeId] ?? 0) + p.thisMoney; + } + + const dateKeys = Object.keys(dayBuckets).sort(); + const running: Record = {}; + for (const tid of typeIds) running[tid] = 0; + + const cumulative: Record = {}; + for (const dateKey of dateKeys) { + for (const tid of typeIds) { + running[tid] += dayBuckets[dateKey][tid] ?? 0; + if (!cumulative[tid]) cumulative[tid] = []; + cumulative[tid].push(running[tid] / 100); + } + } + + // Daily total bar values (yuan) + const dailyTotals = dateKeys.map((dk) => { + let sum = 0; + for (const tid of typeIds) sum += dayBuckets[dk][tid] ?? 0; + return sum / 100; + }); + + const barSeries = { + name: '当日合计', + type: 'bar' as const, + data: dailyTotals, + barWidth: '55%', + itemStyle: { + borderRadius: [4, 4, 0, 0], + color: (p: { value: number }) => (p.value >= 0 ? '#2e7d32' : '#c62828'), + }, + }; + + const series = [ + barSeries, + ...typeIds.map((tid) => ({ + name: TYPE_LABELS[tid] ?? `类型 ${tid}`, + type: 'line' as const, + data: cumulative[tid], + smooth: true, + symbol: 'none' as const, + lineStyle: { color: CHART_COLORS[tid] ?? '#999', width: 2 }, + itemStyle: { color: CHART_COLORS[tid] ?? '#999' }, + })), + ]; + + return { + tooltip: { + trigger: 'axis', + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e0e0e0', + borderWidth: 1, + textStyle: { color: '#333', fontSize: 13 }, + }, + legend: { + bottom: 8, + textStyle: { fontSize: 12, color: '#666' }, + itemWidth: 14, + itemHeight: 10, + }, + grid: { top: 16, left: 64, right: 64, bottom: 48 }, + xAxis: { + type: 'category', + data: dateKeys, + axisLine: { lineStyle: { color: '#e0e0e0' } }, + axisTick: { show: false }, + axisLabel: { color: '#888', fontSize: 11, rotate: 45 }, + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: '#f0f0f0' } }, + axisLabel: { + fontSize: 11, + color: '#888', + formatter: (v: number) => { + if (Math.abs(v) >= 10000) return `¥${(v / 10000).toFixed(1)}w`; + return `¥${v.toFixed(0)}`; + }, + }, + }, + series, + dataZoom: [ + { + type: 'slider', + height: 20, + bottom: 4, + borderColor: 'transparent', + backgroundColor: '#f5f5f5', + fillerColor: 'rgba(21,101,192,0.1)', + handleStyle: { color: '#1565c0', borderColor: '#1565c0' }, + textStyle: { fontSize: 10, color: '#999' }, + }, + { type: 'inside' }, + ], + }; + }, [chartData]); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: 'addTime', + header: '时间', + cell: (info) => { + const v = info.getValue() as number; + return ( + + {new Date(v).toLocaleString('zh-CN')} + + ); + }, + }, + { + accessorKey: 'platform', + header: '平台', + cell: (info) => ( + + {platformLabel[info.getValue() as string] ?? (info.getValue() as string)} + + ), + }, + { + id: 'account', + header: '账户', + cell: (info) => ( + + {accountMap.get(info.row.original.accountId) ?? + String(info.row.original.accountId ?? '—')} + + ), + }, + { + accessorKey: 'typeName', + header: '类型', + cell: (info) => { + const typeId = info.row.original.typeId; + return ( + + ); + }, + }, + { + accessorKey: 'thisMoney', + header: '金额', + meta: { align: 'right' }, + cell: (info) => { + const v = info.getValue() as number; + return ( + = 0 ? 'success.main' : 'error.main'} + > + {formatCNY(v)} + + ); + }, + }, + { + accessorKey: 'orderNo', + header: '订单号', + cell: (info) => { + const v = info.getValue() as string; + if (!v) + return ( + + — + + ); + return ( + + {v.length > 24 ? v.slice(0, 24) + '...' : v} + + ); + }, + }, + ], + [accountMap], + ); + + return ( + + + 资金流水 + + + + {error && ( + + void refetch()} /> + + )} + + {isLoading && ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + + )} + + {!isLoading && !error && totalCount === 0 && ( + 暂无流水记录。同步账户数据后将自动拉取资金流水。 + )} + + {!isLoading && !error && totalCount > 0 && ( + <> + {/* Filter bar */} + + + + + + + + + + setStartDateStr(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 160 }} + /> + + setEndDateStr(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ width: 160 }} + /> + + + {/* Summary cards */} + {chartData.length > 0 && ( + + {Object.keys(typeTotals) + .map(Number) + .sort((a, b) => a - b) + .map((tid) => { + const total = typeTotals[tid]; + return ( + + + + {TYPE_LABELS[tid] ?? `类型 ${tid}`} + + = 0 ? 'success.main' : 'error.main'} + > + {formatCNY(total)} + + + + ); + })} + + )} + + {/* Chart */} + + + + 累计资金流水趋势 + + {chartData.length === 0 ? ( + + 所选筛选条件下没有数据 + + ) : chartOption ? ( + + + + ) : null} + + + + {/* Table */} + {filteredBills.length > 0 && ( + <> + String(b.ID)} + /> + setPage(newPage + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(1); + }} + rowsPerPageOptions={[20, 50, 100]} + labelRowsPerPage="每页行数:" + labelDisplayedRows={({ from, to, count }) => ( + + {from}–{to} of {count} + setJumpInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const n = Number(jumpInput); + if (n >= 1 && n <= totalPages) { + setPage(n); + setJumpInput(''); + } + } + }} + inputProps={{ + inputMode: 'numeric', + style: { width: 40, textAlign: 'center', padding: 0 }, + }} + sx={{ ml: 1, width: 48, '& .MuiInputBase-root': { fontSize: '0.875rem' } }} + /> + + )} + /> + + )} + + {filteredBills.length === 0 && ( + + )} + + )} + + ); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 2e31ea7..e481319 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useBillRecords.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/BillPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 21afc73..bb2c9de 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1,8 +1,9 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT import {model} from '../models'; -import {trade} from '../models'; import {main} from '../models'; +import {bill} from '../models'; +import {trade} from '../models'; import {inventory} from '../models'; import {platform} from '../models'; import {pnl} from '../models'; @@ -14,6 +15,10 @@ export function DeleteAccount(arg1:number):Promise; export function GetAccounts():Promise>; +export function GetBillChartData(arg1:number,arg2:number,arg3:number):Promise>; + +export function GetBillRecords(arg1:number,arg2:number,arg3:number,arg4:number,arg5:string,arg6:number,arg7:number):Promise; + export function GetCompletedTrades(arg1:number,arg2:number,arg3:number,arg4:string,arg5:string):Promise; export function GetCompletedTradesSummary(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 927cb8c..7fcf08c 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,6 +14,14 @@ export function GetAccounts() { return window['go']['main']['App']['GetAccounts'](); } +export function GetBillChartData(arg1, arg2, arg3) { + return window['go']['main']['App']['GetBillChartData'](arg1, arg2, arg3); +} + +export function GetBillRecords(arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + return window['go']['main']['App']['GetBillRecords'](arg1, arg2, arg3, arg4, arg5, arg6, arg7); +} + export function GetCompletedTrades(arg1, arg2, arg3, arg4, arg5) { return window['go']['main']['App']['GetCompletedTrades'](arg1, arg2, arg3, arg4, arg5); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 28a5941..bd9b20c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,40 @@ +export namespace bill { + + export class PaginatedBills { + records: model.BillRecord[]; + totalCount: number; + + static createFrom(source: any = {}) { + return new PaginatedBills(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.records = this.convertValues(source["records"], model.BillRecord); + this.totalCount = source["totalCount"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace inventory { export class InventoryGroup { @@ -145,6 +182,22 @@ export namespace inventory { export namespace main { + export class DailyBillPoint { + date: string; + typeId: number; + thisMoney: number; + + static createFrom(source: any = {}) { + return new DailyBillPoint(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.date = source["date"]; + this.typeId = source["typeId"]; + this.thisMoney = source["thisMoney"]; + } + } export class DashboardSummary { realizedPl: number; inventoryCount: number; @@ -213,6 +266,7 @@ export namespace model { remark: string; status: string; lastSyncAt?: number; + billLastSyncAt?: number; static createFrom(source: any = {}) { return new Account(source); @@ -233,6 +287,60 @@ export namespace model { this.remark = source["remark"]; this.status = source["status"]; this.lastSyncAt = source["lastSyncAt"]; + this.billLastSyncAt = source["billLastSyncAt"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class BillRecord { + ID: number; + // Go type: time + CreatedAt: any; + // Go type: time + UpdatedAt: any; + // Go type: gorm + DeletedAt: any; + accountId: number; + platform: string; + typeId: number; + typeName: string; + thisMoney: number; + orderNo: string; + addTime: number; + + static createFrom(source: any = {}) { + return new BillRecord(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.ID = source["ID"]; + this.CreatedAt = this.convertValues(source["CreatedAt"], null); + this.UpdatedAt = this.convertValues(source["UpdatedAt"], null); + this.DeletedAt = this.convertValues(source["DeletedAt"], null); + this.accountId = source["accountId"]; + this.platform = source["platform"]; + this.typeId = source["typeId"]; + this.typeName = source["typeName"]; + this.thisMoney = source["thisMoney"]; + this.orderNo = source["orderNo"]; + this.addTime = source["addTime"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/go.mod b/go.mod index cbdc91f..8a7f929 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/lmittmann/tint v1.1.3 github.com/mattn/go-sqlite3 v1.14.44 github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v2 v2.12.0 go.uber.org/fx v1.24.0 gopkg.in/yaml.v3 v3.0.1 @@ -57,6 +58,7 @@ require ( github.com/breml/bidichk v0.3.3 // indirect github.com/breml/errchkjson v0.4.1 // indirect github.com/briandowns/spinner v1.23.2 // indirect + github.com/brunoga/deep v1.3.1 // indirect github.com/butuzov/ireturn v0.4.1 // indirect github.com/butuzov/mirror v1.3.0 // indirect github.com/catenacyber/perfsprint v0.10.1 // indirect @@ -84,6 +86,7 @@ require ( github.com/ettle/strcase v0.2.0 // indirect github.com/evilmartians/lefthook v1.13.6 // indirect github.com/fatih/color v1.19.0 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -134,8 +137,10 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/jedib0t/go-pretty/v6 v6.7.8 // indirect github.com/jgautheron/goconst v1.10.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect @@ -151,8 +156,12 @@ require ( github.com/knadh/koanf/parsers/json v1.0.0 // indirect github.com/knadh/koanf/parsers/toml/v2 v2.2.0 // indirect github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect + github.com/knadh/koanf/providers/env v1.1.0 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect github.com/knadh/koanf/providers/fs v1.0.0 // indirect - github.com/knadh/koanf/v2 v2.3.0 // indirect + github.com/knadh/koanf/providers/posflag v1.0.1 // indirect + github.com/knadh/koanf/providers/structs v1.0.0 // indirect + github.com/knadh/koanf/v2 v2.3.2 // indirect github.com/kulti/thelper v0.7.1 // indirect github.com/kunwardeep/paralleltest v1.0.15 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect @@ -212,6 +221,7 @@ require ( github.com/raeperd/recvcheck v0.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryancurrah/gomodguard/v2 v2.1.0 // indirect github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect @@ -233,8 +243,7 @@ require ( github.com/spf13/viper v1.12.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.6 // indirect github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect @@ -248,8 +257,12 @@ require ( github.com/uudashr/iface v1.4.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vektra/mockery/v3 v3.7.0 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect @@ -265,6 +278,7 @@ require ( go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect @@ -285,5 +299,6 @@ require ( tool ( github.com/evilmartians/lefthook github.com/golangci/golangci-lint/v2/cmd/golangci-lint + github.com/vektra/mockery/v3 golang.org/x/tools/cmd/goimports ) diff --git a/go.sum b/go.sum index 482ee08..e8dab82 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,8 @@ github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDw github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/brunoga/deep v1.3.1 h1:bSrL6FhAZa6JlVv4vsi7Hg8SLwroDb1kgDERRVipBCo= +github.com/brunoga/deep v1.3.1/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I= github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= @@ -174,6 +176,7 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -203,6 +206,8 @@ github.com/evilmartians/lefthook v1.13.6 h1:uzuFWpgmqCUg3FoLz0CBkiOHUS/vU3nhB92z github.com/evilmartians/lefthook v1.13.6/go.mod h1:rZdqvPtTVFe+3syrRiY10tG3L6O5+4dz9ZuAMQ5JYn0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E= @@ -268,6 +273,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= @@ -391,11 +397,15 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= +github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw= github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -436,10 +446,18 @@ github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCz github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/fs v1.0.0 h1:tvn4MrduLgdOSUqqEHULUuIcELXf6xDOpH8GUErpYaY= github.com/knadh/koanf/providers/fs v1.0.0/go.mod h1:FksHET+xXFNDozvj8ZCdom54OnZ6eGKJtC5FhZJKx/8= -github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= -github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= +github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -505,8 +523,11 @@ github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= @@ -614,6 +635,9 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= @@ -668,8 +692,8 @@ github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -705,12 +729,21 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektra/mockery/v3 v3.7.0 h1:Dd0EeaOcRJBVP9n3oYOVPV7KdPaaE3EcwTppaZIsFSM= +github.com/vektra/mockery/v3 v3.7.0/go.mod h1:z9Wr23Ha8etImqQwS3boTNR9WkjX6tIklW5c88DRkSw= github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -775,8 +808,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= @@ -908,9 +941,11 @@ golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= diff --git a/lefthook.yml b/lefthook.yml index c77684b..2f73a8e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,11 @@ pre-commit: run: go tool goimports -w -local cs2-ledger {staged_files} stage_fixed: true + mockery: + glob: "*.go" + run: go generate ./... + stage_fixed: true + golangci-lint: glob: "*.go" run: go tool golangci-lint run --new-from-rev=HEAD~1 --timeout=2m diff --git a/main.go b/main.go index 120dca7..1ba91f0 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/platform/factory" "github.com/CsJsss/CS2Ledger/pkg/service" "github.com/CsJsss/CS2Ledger/pkg/service/account" + "github.com/CsJsss/CS2Ledger/pkg/service/bill" "github.com/CsJsss/CS2Ledger/pkg/service/inventory" "github.com/CsJsss/CS2Ledger/pkg/service/market" "github.com/CsJsss/CS2Ledger/pkg/service/pnl" @@ -46,6 +47,7 @@ func main() { account.Module, trade.Module, rental.Module, + bill.Module, pnl.Module, inventory.Module, market.Module, diff --git a/migrations/004_bill_records.sql b/migrations/004_bill_records.sql new file mode 100644 index 0000000..6360455 --- /dev/null +++ b/migrations/004_bill_records.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS bill_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + type_id INTEGER NOT NULL, + type_name TEXT NOT NULL, + this_money INTEGER NOT NULL, + order_no TEXT DEFAULT '', + add_time INTEGER NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME +); + +CREATE INDEX IF NOT EXISTS idx_bill_account ON bill_records(account_id, add_time DESC); +CREATE INDEX IF NOT EXISTS idx_bill_order ON bill_records(order_no); + +-- Speed up filtered pagination (account_id + type_id + time sort) +-- Covers: WHERE account_id=? AND type_id=? ORDER BY add_time DESC +-- Also helps: SumRentalIncome WHERE account_id=? AND type_id IN (...) +CREATE INDEX IF NOT EXISTS idx_bill_account_type_time ON bill_records(account_id, type_id, add_time DESC); + +-- Speed up platform filter combined with account +-- Covers: WHERE account_id=? AND platform=? ORDER BY add_time DESC +CREATE INDEX IF NOT EXISTS idx_bill_account_platform_time ON bill_records(account_id, platform, add_time DESC); + + +ALTER TABLE accounts ADD COLUMN bill_last_sync_at INTEGER; \ No newline at end of file diff --git a/pkg/model/account.go b/pkg/model/account.go index a476049..0b0c6e2 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -14,6 +14,7 @@ type Account struct { Remark string `json:"remark"` Status string `gorm:"not null;default:active" json:"status"` LastSyncAt *int64 `json:"lastSyncAt"` + BillLastSyncAt *int64 `json:"billLastSyncAt"` } func (Account) TableName() string { return "accounts" } diff --git a/pkg/model/bill.go b/pkg/model/bill.go new file mode 100644 index 0000000..debc602 --- /dev/null +++ b/pkg/model/bill.go @@ -0,0 +1,59 @@ +package model + +import "gorm.io/gorm" + +// BillType constants are our internal classification for common transaction types. +// Each platform converts its own type_id into one of these constants via a mapping +// function (e.g. youpinTypeToInternal). Unrecognized types fall back to BillTypeOther. +// +// TypeID vs TypeName: +// - TypeID is our internal constant (BillTypePurchase, etc.). Frontend uses it for +// color coding and filtering across platforms. +// - TypeName is a unified label from BillTypeName(). When TypeID == BillTypeOther, +// the converter falls back to the platform's original type name. +const ( + BillTypePurchase = 1 // 购买 + BillTypeSell = 2 // 出售 + BillTypeRentalIncome = 3 // 收取租金 + BillTypeRenewalRental = 4 // 收取续租资金 + BillTypeRentalFee = 5 // 租赁服务费 + BillTypeRecharge = 6 // 充值 + BillTypeWithdraw = 7 // 提现 + BillTypeRefund = 8 // 退款 + BillTypeRechargForPurchaseAccount = 9 // 求购账户充值 + BillTypeWithdrawRefund = 10 // 提现退款 + BillTypeOther = 99 // 其他 — 回退到平台原始 TypeName 展示 +) + +var billTypeNames = map[int]string{ + BillTypePurchase: "购买", + BillTypeSell: "出售", + BillTypeRentalIncome: "收取租金", + BillTypeRenewalRental: "收取续租资金", + BillTypeRentalFee: "租赁服务费", + BillTypeRecharge: "充值", + BillTypeWithdraw: "提现", + BillTypeRefund: "退款", + BillTypeRechargForPurchaseAccount: "求购账户充值", + BillTypeWithdrawRefund: "提现退款", +} + +func BillTypeName(t int) string { + if name, ok := billTypeNames[t]; ok { + return name + } + return "" +} + +type BillRecord struct { + gorm.Model + AccountID uint `gorm:"not null;index:idx_bill_account" json:"accountId"` + Platform string `gorm:"not null" json:"platform"` + TypeID int `gorm:"not null" json:"typeId"` + TypeName string `gorm:"not null" json:"typeName"` + ThisMoney int64 `gorm:"not null" json:"thisMoney"` + OrderNo string `gorm:"index" json:"orderNo"` + AddTime int64 `gorm:"not null;index" json:"addTime"` +} + +func (BillRecord) TableName() string { return "bill_records" } diff --git a/pkg/orm/account.go b/pkg/orm/account.go index 42d6544..d2ff54d 100644 --- a/pkg/orm/account.go +++ b/pkg/orm/account.go @@ -51,3 +51,8 @@ func (o *ormImpl) UpdateAccountBalanceAndSyncTime(id uint, available, frozen, in "last_sync_at": syncAt, }).Error } + +func (o *ormImpl) UpdateAccountBillSyncTime(id uint, syncAt int64) error { + return o.db.Model(&model.Account{}).Where("id = ?", id). + Update("bill_last_sync_at", syncAt).Error +} diff --git a/pkg/orm/bill.go b/pkg/orm/bill.go new file mode 100644 index 0000000..89ef8e3 --- /dev/null +++ b/pkg/orm/bill.go @@ -0,0 +1,103 @@ +package orm + +import ( + "gorm.io/gorm" + + "github.com/CsJsss/CS2Ledger/pkg/model" +) + +type BillFilter struct { + TypeID int + Platform string + StartTime int64 // unix ms, 0 = no filter + EndTime int64 // unix ms, 0 = no filter +} + +func applyBillFilter(q *gorm.DB, f BillFilter) *gorm.DB { + if f.TypeID != 0 { + q = q.Where("type_id = ?", f.TypeID) + } + if f.Platform != "" { + q = q.Where("platform = ?", f.Platform) + } + if f.StartTime > 0 { + q = q.Where("add_time >= ?", f.StartTime) + } + if f.EndTime > 0 { + q = q.Where("add_time <= ?", f.EndTime) + } + return q +} + +func (o *ormImpl) CreateBill(r *model.BillRecord) error { + return o.db.Create(r).Error +} + +func (o *ormImpl) ListBillsByAccount(accountID uint, limit, offset int, f BillFilter) ([]model.BillRecord, error) { + var records []model.BillRecord + q := o.db.Where("account_id = ?", accountID) + q = applyBillFilter(q, f) + err := q.Order("add_time DESC").Limit(limit).Offset(offset).Find(&records).Error + return records, err +} + +func (o *ormImpl) ListAllBills(limit, offset int, f BillFilter) ([]model.BillRecord, error) { + var records []model.BillRecord + q := o.db. + Joins("JOIN accounts ON accounts.id = bill_records.account_id"). + Where("accounts.deleted_at IS NULL") + q = applyBillFilter(q, f) + err := q.Order("add_time DESC").Limit(limit).Offset(offset).Find(&records).Error + return records, err +} + +func (o *ormImpl) CountBillsByAccount(accountID uint, f BillFilter) (int64, error) { + var count int64 + q := o.db.Model(&model.BillRecord{}).Where("account_id = ?", accountID) + q = applyBillFilter(q, f) + err := q.Count(&count).Error + return count, err +} + +func (o *ormImpl) CountAllBills(f BillFilter) (int64, error) { + var count int64 + q := o.db.Model(&model.BillRecord{}). + Joins("JOIN accounts ON accounts.id = bill_records.account_id"). + Where("accounts.deleted_at IS NULL") + q = applyBillFilter(q, f) + err := q.Count(&count).Error + return count, err +} + +// DailyBillSummary is a single day's aggregated bill totals (in fen). +type DailyBillSummary struct { + Date string // "2006-01-02" + TypeID int + ThisMoney int64 +} + +// SumBillByDay returns daily total this_money grouped by type_id, for chart rendering. +func (o *ormImpl) SumBillByDay(accountID uint, f BillFilter) ([]DailyBillSummary, error) { + var rows []DailyBillSummary + q := o.db.Model(&model.BillRecord{}). + Select("DATE(ROUND(add_time / 1000), 'unixepoch') AS date, type_id, SUM(this_money) AS this_money") + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + q = applyBillFilter(q, f) + err := q.Group("date, type_id").Order("date ASC").Scan(&rows).Error + return rows, err +} + +func (o *ormImpl) SumBillsByTypes(accountID uint, typeIDs []int) (int64, error) { + var sum int64 + q := o.db.Model(&model.BillRecord{}) + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + if len(typeIDs) > 0 { + q = q.Where("type_id IN ?", typeIDs) + } + err := q.Select("COALESCE(SUM(this_money), 0)").Scan(&sum).Error + return sum, err +} diff --git a/pkg/orm/interfaces.go b/pkg/orm/interfaces.go index d2203b4..677cd6d 100644 --- a/pkg/orm/interfaces.go +++ b/pkg/orm/interfaces.go @@ -11,6 +11,7 @@ type AccountInterface interface { UpdateAccountInfo(id uint, name string, cookie string) error UpdateAccountStatus(id uint, status string) error UpdateAccountBalanceAndSyncTime(id uint, available, frozen, instant, purchase int64, syncAt int64) error + UpdateAccountBillSyncTime(id uint, syncAt int64) error } type TradeInterface interface { @@ -59,10 +60,21 @@ type RentalInterface interface { FindRentalsByAccount(accountID uint) ([]model.RentalRecord, error) } +type BillInterface interface { + CreateBill(*model.BillRecord) error + ListBillsByAccount(accountID uint, limit, offset int, f BillFilter) ([]model.BillRecord, error) + ListAllBills(limit, offset int, f BillFilter) ([]model.BillRecord, error) + CountBillsByAccount(accountID uint, f BillFilter) (int64, error) + CountAllBills(f BillFilter) (int64, error) + SumBillsByTypes(accountID uint, typeIDs []int) (int64, error) + SumBillByDay(accountID uint, f BillFilter) ([]DailyBillSummary, error) +} + type ORMInterface interface { AccountInterface TradeInterface InventoryInterface PnlInterface RentalInterface + BillInterface } diff --git a/pkg/orm/orm.go b/pkg/orm/orm.go index 6119265..8c0f454 100644 --- a/pkg/orm/orm.go +++ b/pkg/orm/orm.go @@ -60,6 +60,7 @@ func NewTestORM() (ORMInterface, *GormDB, error) { &model.InventoryItem{}, &model.RentalRecord{}, &model.PnlDaily{}, + &model.BillRecord{}, ); err != nil { _ = sqlDB.Close() return nil, nil, fmt.Errorf("orm: automigrate: %w", err) diff --git a/pkg/platform/buff/client.go b/pkg/platform/buff/client.go index c55d977..5d6c5c8 100644 --- a/pkg/platform/buff/client.go +++ b/pkg/platform/buff/client.go @@ -51,6 +51,20 @@ func (c *Client) primeSession() { }) } +// parseBuff unmarshals a buffResponse envelope and checks Code is "OK". +func parseBuff[T any](body []byte) (T, error) { + var result buffResponse[T] + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + if result.Code != "OK" { + var zero T + return zero, fmt.Errorf("buff API error: code=%s", result.Code) + } + return result.Data, nil +} + func (c *Client) Verify(ctx context.Context) error { c.primeSession() c.Log.DebugContext(ctx, "verifying") @@ -78,8 +92,7 @@ func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption c.Log.Info("fetching buy history", "since", cfg.Since) trades, err := platform.FetchAllPages(ctx, c.Log, c.Name, model.DirectionBuy, 1*time.Second, cfg.Limit, func(ctx context.Context, page int) ([]platform.TradeRecord, bool, error) { - items, hasMore, _, err := c.fetchBuyPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams) - return items, hasMore, err + return c.fetchBuyPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams) }, ) if err != nil { @@ -89,28 +102,25 @@ func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption return trades, nil } -func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string) ([]platform.TradeRecord, bool, int, error) { +func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string) ([]platform.TradeRecord, bool, error) { query := map[string]string{"game": "csgo", "page_num": strconv.Itoa(page), "page_size": DefaultPageSize} for k, v := range extra { query[k] = v } _, body, err := c.doRequest(ctx, "GET", "/api/market/buy_order/history", query, nil) if err != nil { - return nil, false, 0, err + return nil, false, err } - var result buyOrderHistoryResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, false, 0, err - } - if result.Code != "OK" { - return nil, false, 0, fmt.Errorf("API error: code=%s", result.Code) + data, err := parseBuff[tradeHistoryData[buyOrderItem]](body) + if err != nil { + return nil, false, err } sinceSec := since / 1000 - trades := make([]platform.TradeRecord, 0, len(result.Data.Items)) + trades := make([]platform.TradeRecord, 0, len(data.Items)) finished := false - for _, item := range result.Data.Items { + for _, item := range data.Items { if item.TransactTime < sinceSec { finished = true continue @@ -118,37 +128,34 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS if tradeState == platform.TradeStateCompleted && item.State != StatusSuccess { continue } - trades = append(trades, toBuyTrade(item, result.Data.GoodsInfos)) + trades = append(trades, toBuyTrade(item, data.GoodsInfos)) } - c.Log.Debug("buy history", "page", page, "items", len(trades), "total_pages", result.Data.TotalPages, "total", result.Data.Total) - if len(result.Data.Items) == 0 || finished || (result.Data.TotalPages > 0 && page >= result.Data.TotalPages) { - return trades, false, result.Data.TotalPages, nil + c.Log.Debug("buy history", "page", page, "items", len(trades), "total_pages", data.TotalPages, "total", data.Total) + if len(data.Items) == 0 || finished || (data.TotalPages > 0 && page >= data.TotalPages) { + return trades, false, nil } - return trades, true, result.Data.TotalPages, nil + return trades, true, nil } -func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string) ([]platform.TradeRecord, bool, int, error) { +func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string) ([]platform.TradeRecord, bool, error) { query := map[string]string{"appid": "730", "mode": "1", "page_num": strconv.Itoa(page), "page_size": DefaultPageSize} for k, v := range extra { query[k] = v } _, body, err := c.doRequest(ctx, "GET", "/api/market/sell_order/history", query, nil) if err != nil { - return nil, false, 0, err + return nil, false, err } - var result sellOrderHistoryResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, false, 0, err - } - if result.Code != "OK" { - return nil, false, 0, fmt.Errorf("buff API error: code=%s", result.Code) + data, err := parseBuff[tradeHistoryData[sellOrderItem]](body) + if err != nil { + return nil, false, err } sinceSec := since / 1000 - trades := make([]platform.TradeRecord, 0, len(result.Data.Items)) + trades := make([]platform.TradeRecord, 0, len(data.Items)) finished := false - for _, item := range result.Data.Items { + for _, item := range data.Items { if item.CreateTime < sinceSec { finished = true continue @@ -156,13 +163,13 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade if tradeState == platform.TradeStateCompleted && item.State != StatusSuccess { continue } - trades = append(trades, toSellTrade(item, result.Data.GoodsInfos)) + trades = append(trades, toSellTrade(item, data.GoodsInfos)) } - c.Log.Debug("sell history", "page", page, "items", len(trades), "total_pages", result.Data.TotalPages, "total", result.Data.Total) - if len(result.Data.Items) == 0 || finished || (result.Data.TotalPages > 0 && page >= result.Data.TotalPages) { - return trades, false, result.Data.TotalPages, nil + c.Log.Debug("sell history", "page", page, "items", len(trades), "total_pages", data.TotalPages, "total", data.Total) + if len(data.Items) == 0 || finished || (data.TotalPages > 0 && page >= data.TotalPages) { + return trades, false, nil } - return trades, true, result.Data.TotalPages, nil + return trades, true, nil } func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { @@ -171,8 +178,7 @@ func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOptio c.Log.Info("fetching sell history", "since", cfg.Since) trades, err := platform.FetchAllPages(ctx, c.Log, c.Name, model.DirectionSell, 1*time.Second, cfg.Limit, func(ctx context.Context, page int) ([]platform.TradeRecord, bool, error) { - items, hasMore, _, err := c.fetchSellPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams) - return items, hasMore, err + return c.fetchSellPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams) }, ) if err != nil { @@ -190,17 +196,14 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { return nil, fmt.Errorf("buff balance: %w", err) } - var result balanceResponse - if err := json.Unmarshal(body, &result); err != nil { + data, err := parseBuff[balanceData](body) + if err != nil { return nil, err } - if result.Code != "OK" { - return nil, fmt.Errorf("buff balance: code=%s", result.Code) - } - available, _ := strconv.ParseFloat(result.Data.CashAmount, 64) - purchase, _ := strconv.ParseFloat(result.Data.SecurityAmount, 64) - frozen, _ := strconv.ParseFloat(result.Data.FrozenAmount, 64) + available, _ := strconv.ParseFloat(data.CashAmount, 64) + purchase, _ := strconv.ParseFloat(data.SecurityAmount, 64) + frozen, _ := strconv.ParseFloat(data.FrozenAmount, 64) return &platform.Balance{ Available: available, @@ -210,6 +213,10 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { }, nil } +func (c *Client) GetBillHistory(_ context.Context, _ ...platform.QueryOption) ([]platform.BillRecord, error) { + return nil, nil +} + func (c *Client) headers() http.Header { h := http.Header{} h.Set("User-Agent", platform.RandomUA()) diff --git a/pkg/platform/buff/model.go b/pkg/platform/buff/model.go index 9211d1d..2c2872f 100644 --- a/pkg/platform/buff/model.go +++ b/pkg/platform/buff/model.go @@ -2,6 +2,21 @@ package buff // --- Shared --- +// buffResponse is a generic envelope for BUFF API responses. +type buffResponse[T any] struct { + Code string `json:"code"` + Msg *string `json:"msg,omitempty"` + Data T `json:"data"` +} + +// tradeHistoryData is shared by buy/sell order history responses. +type tradeHistoryData[T any] struct { + Items []T `json:"items"` + GoodsInfos map[string]goodInfo `json:"goods_infos"` + TotalPages int `json:"total_page"` + Total int `json:"total_count"` +} + type goodInfo struct { Name string `json:"name"` ShortName string `json:"short_name"` @@ -74,18 +89,6 @@ type userInfoResponse struct { // --- Buy history --- -type buyOrderHistoryResponse struct { - Code string `json:"code"` - Data buyOrderHistoryData `json:"data"` -} - -type buyOrderHistoryData struct { - Items []buyOrderItem `json:"items"` - GoodsInfos map[string]goodInfo `json:"goods_infos"` - TotalPages int `json:"total_page"` - Total int `json:"total_count"` -} - type buyOrderItem struct { ID string `json:"id"` State string `json:"state"` @@ -103,18 +106,6 @@ type buyOrderItem struct { // --- Sell history --- -type sellOrderHistoryResponse struct { - Code string `json:"code"` - Data sellOrderHistoryData `json:"data"` -} - -type sellOrderHistoryData struct { - Items []sellOrderItem `json:"items"` - GoodsInfos map[string]goodInfo `json:"goods_infos"` - TotalPages int `json:"total_page"` - Total int `json:"total_count"` -} - type sellOrderItem struct { ID string `json:"id"` GoodsID int64 `json:"goods_id"` @@ -131,12 +122,6 @@ type sellOrderItem struct { // --- Balance --- -type balanceResponse struct { - Code string `json:"code"` - Msg *string `json:"msg"` - Data balanceData `json:"data"` -} - type balanceData struct { CashAmount string `json:"cash_amount"` CashAmountOuter string `json:"cash_amount_outer"` diff --git a/pkg/platform/c5/client.go b/pkg/platform/c5/client.go index 2da4c53..af9642f 100644 --- a/pkg/platform/c5/client.go +++ b/pkg/platform/c5/client.go @@ -28,6 +28,20 @@ func New(apiKey string, logger *logfx.Logger) *Client { } } +// parseC5 unmarshals a c5Response envelope and checks Success. +func parseC5[T any](body []byte) (T, error) { + var result c5Response[T] + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + if !result.Success { + var zero T + return zero, fmt.Errorf("c5 API error: code=%d msg=%s", result.ErrorCode, result.ErrorMsg) + } + return result.Data, nil +} + func (c *Client) Verify(ctx context.Context) error { c.Log.Info("c5: verifying") _, body, err := c.doRequest(ctx, "GET", "/merchant/account/v2/balance", nil, nil) @@ -36,13 +50,9 @@ func (c *Client) Verify(ctx context.Context) error { return fmt.Errorf("c5 verify: %w", err) } - var result c5BalanceV2Response - if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("c5 verify: %w", err) - } - if !result.Success { - c.Log.Warn("c5: verify invalid credential", "errorCode", result.ErrorCode, "errorMsg", result.ErrorMsg) - return fmt.Errorf("c5 verify: credential invalid (code=%d msg=%s)", result.ErrorCode, result.ErrorMsg) + if _, err := parseC5[c5BalanceV2Data](body); err != nil { + c.Log.Warn("c5: verify invalid credential", "err", err) + return fmt.Errorf("c5 verify: credential invalid: %w", err) } c.Log.Info("c5: verify ok") return nil @@ -55,21 +65,22 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { return nil, fmt.Errorf("c5 balance: %w", err) } - var result c5BalanceV2Response - if err := json.Unmarshal(body, &result); err != nil { + data, err := parseC5[c5BalanceV2Data](body) + if err != nil { return nil, fmt.Errorf("c5 balance: %w", err) } - if !result.Success { - return nil, fmt.Errorf("c5 balance: API error code=%d msg=%s", result.ErrorCode, result.ErrorMsg) - } return &platform.Balance{ - Available: result.Data.MoneyAmount, - Frozen: result.Data.TradeSettleAmount, - Instant: result.Data.CreditMoney, + Available: data.MoneyAmount, + Frozen: data.TradeSettleAmount, + Instant: data.CreditMoney, }, nil } +func (c *Client) GetBillHistory(_ context.Context, _ ...platform.QueryOption) ([]platform.BillRecord, error) { + return nil, nil +} + func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { cfg := platform.ApplyQueryOpts(opts) c.Log.Info("c5: fetching buy history", "since", cfg.Since) @@ -95,18 +106,15 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS return nil, false, err } - var result c5BuyerOrderResponse - if err := json.Unmarshal(respBody, &result); err != nil { + data, err := parseC5[c5BuyerOrderData](respBody) + if err != nil { return nil, false, err } - if !result.Success { - return nil, false, fmt.Errorf("c5 buyer order API error: code=%d msg=%s", result.ErrorCode, result.ErrorMsg) - } sinceSec := since / 1000 - filtered := make([]c5BuyerOrder, 0, len(result.Data.List)) + filtered := make([]c5BuyerOrder, 0, len(data.List)) finished := false - for _, item := range result.Data.List { + for _, item := range data.List { if item.CreateTime < sinceSec { finished = true continue @@ -119,10 +127,10 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS trades := c.enrichBuyerOrders(ctx, filtered) - if len(result.Data.List) == 0 || finished { + if len(data.List) == 0 || finished { return trades, false, nil } - hasMore := page < result.Data.Pages + hasMore := page < data.Pages return trades, hasMore, nil } @@ -157,14 +165,7 @@ func (c *Client) getOrderDetail(ctx context.Context, orderID string) (c5BuyerOrd if err != nil { return c5BuyerOrderDetail{}, err } - var resp c5BuyerOrderDetailResponse - if err := json.Unmarshal(body, &resp); err != nil { - return c5BuyerOrderDetail{}, err - } - if !resp.Success { - return c5BuyerOrderDetail{}, fmt.Errorf("c5 order detail API error for %s: code=%d msg=%s", orderID, resp.ErrorCode, resp.ErrorMsg) - } - return resp.Data, nil + return parseC5[c5BuyerOrderDetail](body) } func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { @@ -199,17 +200,14 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade return nil, false, err } - var result c5SellerOrderResponse - if err := json.Unmarshal(body, &result); err != nil { + data, err := parseC5[c5SellerOrderData](body) + if err != nil { return nil, false, err } - if !result.Success { - return nil, false, fmt.Errorf("c5 seller order API error: code=%d msg=%s", result.ErrorCode, result.ErrorMsg) - } - trades := make([]platform.TradeRecord, 0, len(result.Data.List)) + trades := make([]platform.TradeRecord, 0, len(data.List)) finished := false - for _, item := range result.Data.List { + for _, item := range data.List { tradeAt := int64(0) if item.OrderConfirmInfo != nil { tradeAt = item.OrderConfirmInfo.OrderCreateTime * 1000 @@ -224,10 +222,10 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade trades = append(trades, toSellerTrade(item)) } - if len(result.Data.List) == 0 || finished { + if len(data.List) == 0 || finished { return trades, false, nil } - hasMore := page < result.Data.Pages + hasMore := page < data.Pages return trades, hasMore, nil } diff --git a/pkg/platform/c5/model.go b/pkg/platform/c5/model.go index f7adfc0..22c5b0a 100644 --- a/pkg/platform/c5/model.go +++ b/pkg/platform/c5/model.go @@ -16,16 +16,17 @@ func isCompletedStatus(s int) bool { return s == StatusCompleted || s == StatusSuccessV2 } -// Balance v2 - -type c5BalanceV2Response struct { - Success bool `json:"success"` - ErrorCode int `json:"errorCode"` - ErrorMsg string `json:"errorMsg"` - ErrorCodeStr string `json:"errorCodeStr"` - Data c5BalanceV2Data `json:"data"` +// c5Response is a generic envelope for C5 API responses. +type c5Response[T any] struct { + Success bool `json:"success"` + ErrorCode int `json:"errorCode"` + ErrorMsg string `json:"errorMsg"` + ErrorCodeStr string `json:"errorCodeStr"` + Data T `json:"data"` } +// Balance v2 + type c5BalanceV2Data struct { UserID string `json:"userId"` MoneyAmount float64 `json:"moneyAmount"` @@ -37,14 +38,6 @@ type c5BalanceV2Data struct { // Buyer order v2 (POST /merchant/order/v2/buyer/status) -type c5BuyerOrderResponse struct { - Success bool `json:"success"` - ErrorCode int `json:"errorCode"` - ErrorMsg string `json:"errorMsg"` - ErrorCodeStr string `json:"errorCodeStr"` - Data c5BuyerOrderData `json:"data"` -} - type c5BuyerOrderData struct { Total string `json:"total"` Pages int `json:"pages"` @@ -69,14 +62,6 @@ type c5BuyerOrder struct { // Seller order v1 (GET /merchant/order/v1/list) -type c5SellerOrderResponse struct { - Success bool `json:"success"` - ErrorCode int `json:"errorCode"` - ErrorMsg string `json:"errorMsg"` - ErrorCodeStr string `json:"errorCodeStr"` - Data c5SellerOrderData `json:"data"` -} - type c5SellerOrderData struct { Total string `json:"total"` Pages int `json:"pages"` @@ -102,14 +87,6 @@ type c5OrderConfirmInfo struct { // Order detail v2 (GET /merchant/order/v2/buy/detail) -type c5BuyerOrderDetailResponse struct { - Success bool `json:"success"` - ErrorCode int `json:"errorCode"` - ErrorMsg string `json:"errorMsg"` - ErrorCodeStr string `json:"errorCodeStr"` - Data c5BuyerOrderDetail `json:"data"` -} - type c5BuyerOrderDetail struct { OrderID string `json:"orderId"` ProductID string `json:"productId"` diff --git a/pkg/platform/client.go b/pkg/platform/client.go index c4b70b3..1490dab 100644 --- a/pkg/platform/client.go +++ b/pkg/platform/client.go @@ -1,5 +1,7 @@ package platform +//go:generate go tool mockery + import ( "context" @@ -48,11 +50,24 @@ type Balance struct { Instant float64 // 秒到账余额 } +// BillRecord is a unified bill/transaction record returned by platform clients. +// ThisMoney is in cents; positive = income, negative = expense. +// AddTime is a unix millisecond timestamp. +type BillRecord struct { + TypeName string + TypeID int + ThisMoney int64 + OrderNo string + AddTime int64 +} + // QueryConfig holds optional parameters for history queries. type QueryConfig struct { Since int64 // unix ms, 0 = no filter Limit int // max records, 0 = no limit TradeState TradeState // order completion filter, default = all + Page int // single page to fetch, 0 = all pages + PageSize int // page size, 0 = platform default ExtraParams map[string]string // merged into HTTP request params } @@ -81,6 +96,16 @@ func WithLimit(limit int) QueryOption { return func(c *QueryConfig) { c.Limit = limit } } +// WithPage fetches a single page (1-based). 0 = fetch all pages. +func WithPage(page int) QueryOption { + return func(c *QueryConfig) { c.Page = page } +} + +// WithPageSize sets the page size for paginated requests. +func WithPageSize(pageSize int) QueryOption { + return func(c *QueryConfig) { c.PageSize = pageSize } +} + func ApplyQueryOpts(opts []QueryOption) QueryConfig { cfg := QueryConfig{} for _, o := range opts { @@ -90,9 +115,13 @@ func ApplyQueryOpts(opts []QueryOption) QueryConfig { } // Client is the interface all platform clients must implement. +// +//mockery:generate: true +//mockery:filename: client_mock_test.go type Client interface { Verify(ctx context.Context) error GetBuyHistory(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error) GetSellHistory(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error) GetBalance(ctx context.Context) (*Balance, error) + GetBillHistory(ctx context.Context, opts ...QueryOption) ([]BillRecord, error) } diff --git a/pkg/platform/client_mock_test.go b/pkg/platform/client_mock_test.go new file mode 100644 index 0000000..56a3b36 --- /dev/null +++ b/pkg/platform/client_mock_test.go @@ -0,0 +1,382 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package platform + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetBalance provides a mock function for the type MockClient +func (_mock *MockClient) GetBalance(ctx context.Context) (*Balance, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetBalance") + } + + var r0 *Balance + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (*Balance, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) *Balance); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Balance) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetBalance_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBalance' +type MockClient_GetBalance_Call struct { + *mock.Call +} + +// GetBalance is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetBalance(ctx interface{}) *MockClient_GetBalance_Call { + return &MockClient_GetBalance_Call{Call: _e.mock.On("GetBalance", ctx)} +} + +func (_c *MockClient_GetBalance_Call) Run(run func(ctx context.Context)) *MockClient_GetBalance_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockClient_GetBalance_Call) Return(balance *Balance, err error) *MockClient_GetBalance_Call { + _c.Call.Return(balance, err) + return _c +} + +func (_c *MockClient_GetBalance_Call) RunAndReturn(run func(ctx context.Context) (*Balance, error)) *MockClient_GetBalance_Call { + _c.Call.Return(run) + return _c +} + +// GetBillHistory provides a mock function for the type MockClient +func (_mock *MockClient) GetBillHistory(ctx context.Context, opts ...QueryOption) ([]BillRecord, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for GetBillHistory") + } + + var r0 []BillRecord + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) ([]BillRecord, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) []BillRecord); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]BillRecord) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...QueryOption) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetBillHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBillHistory' +type MockClient_GetBillHistory_Call struct { + *mock.Call +} + +// GetBillHistory is a helper method to define mock.On call +// - ctx context.Context +// - opts ...QueryOption +func (_e *MockClient_Expecter) GetBillHistory(ctx interface{}, opts ...interface{}) *MockClient_GetBillHistory_Call { + return &MockClient_GetBillHistory_Call{Call: _e.mock.On("GetBillHistory", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *MockClient_GetBillHistory_Call) Run(run func(ctx context.Context, opts ...QueryOption)) *MockClient_GetBillHistory_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []QueryOption + var variadicArgs []QueryOption + if len(args) > 1 { + variadicArgs = args[1].([]QueryOption) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *MockClient_GetBillHistory_Call) Return(billRecords []BillRecord, err error) *MockClient_GetBillHistory_Call { + _c.Call.Return(billRecords, err) + return _c +} + +func (_c *MockClient_GetBillHistory_Call) RunAndReturn(run func(ctx context.Context, opts ...QueryOption) ([]BillRecord, error)) *MockClient_GetBillHistory_Call { + _c.Call.Return(run) + return _c +} + +// GetBuyHistory provides a mock function for the type MockClient +func (_mock *MockClient) GetBuyHistory(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for GetBuyHistory") + } + + var r0 []TradeRecord + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) ([]TradeRecord, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) []TradeRecord); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]TradeRecord) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...QueryOption) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetBuyHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBuyHistory' +type MockClient_GetBuyHistory_Call struct { + *mock.Call +} + +// GetBuyHistory is a helper method to define mock.On call +// - ctx context.Context +// - opts ...QueryOption +func (_e *MockClient_Expecter) GetBuyHistory(ctx interface{}, opts ...interface{}) *MockClient_GetBuyHistory_Call { + return &MockClient_GetBuyHistory_Call{Call: _e.mock.On("GetBuyHistory", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *MockClient_GetBuyHistory_Call) Run(run func(ctx context.Context, opts ...QueryOption)) *MockClient_GetBuyHistory_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []QueryOption + var variadicArgs []QueryOption + if len(args) > 1 { + variadicArgs = args[1].([]QueryOption) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *MockClient_GetBuyHistory_Call) Return(tradeRecords []TradeRecord, err error) *MockClient_GetBuyHistory_Call { + _c.Call.Return(tradeRecords, err) + return _c +} + +func (_c *MockClient_GetBuyHistory_Call) RunAndReturn(run func(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error)) *MockClient_GetBuyHistory_Call { + _c.Call.Return(run) + return _c +} + +// GetSellHistory provides a mock function for the type MockClient +func (_mock *MockClient) GetSellHistory(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error) { + var tmpRet mock.Arguments + if len(opts) > 0 { + tmpRet = _mock.Called(ctx, opts) + } else { + tmpRet = _mock.Called(ctx) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for GetSellHistory") + } + + var r0 []TradeRecord + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) ([]TradeRecord, error)); ok { + return returnFunc(ctx, opts...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, ...QueryOption) []TradeRecord); ok { + r0 = returnFunc(ctx, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]TradeRecord) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, ...QueryOption) error); ok { + r1 = returnFunc(ctx, opts...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetSellHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSellHistory' +type MockClient_GetSellHistory_Call struct { + *mock.Call +} + +// GetSellHistory is a helper method to define mock.On call +// - ctx context.Context +// - opts ...QueryOption +func (_e *MockClient_Expecter) GetSellHistory(ctx interface{}, opts ...interface{}) *MockClient_GetSellHistory_Call { + return &MockClient_GetSellHistory_Call{Call: _e.mock.On("GetSellHistory", + append([]interface{}{ctx}, opts...)...)} +} + +func (_c *MockClient_GetSellHistory_Call) Run(run func(ctx context.Context, opts ...QueryOption)) *MockClient_GetSellHistory_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 []QueryOption + var variadicArgs []QueryOption + if len(args) > 1 { + variadicArgs = args[1].([]QueryOption) + } + arg1 = variadicArgs + run( + arg0, + arg1..., + ) + }) + return _c +} + +func (_c *MockClient_GetSellHistory_Call) Return(tradeRecords []TradeRecord, err error) *MockClient_GetSellHistory_Call { + _c.Call.Return(tradeRecords, err) + return _c +} + +func (_c *MockClient_GetSellHistory_Call) RunAndReturn(run func(ctx context.Context, opts ...QueryOption) ([]TradeRecord, error)) *MockClient_GetSellHistory_Call { + _c.Call.Return(run) + return _c +} + +// Verify provides a mock function for the type MockClient +func (_mock *MockClient) Verify(ctx context.Context) error { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = returnFunc(ctx) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type MockClient_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) Verify(ctx interface{}) *MockClient_Verify_Call { + return &MockClient_Verify_Call{Call: _e.mock.On("Verify", ctx)} +} + +func (_c *MockClient_Verify_Call) Run(run func(ctx context.Context)) *MockClient_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockClient_Verify_Call) Return(err error) *MockClient_Verify_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_Verify_Call) RunAndReturn(run func(ctx context.Context) error) *MockClient_Verify_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/platform/client_test.go b/pkg/platform/client_test.go new file mode 100644 index 0000000..8e30890 --- /dev/null +++ b/pkg/platform/client_test.go @@ -0,0 +1,238 @@ +package platform + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func nopLogger() *logfx.Logger { return logfx.NewNop() } + +// --------------------------------------------------------------------------- +// QueryOption / ApplyQueryOpts +// --------------------------------------------------------------------------- + +func TestApplyQueryOpts_Defaults(t *testing.T) { + cfg := ApplyQueryOpts(nil) + assert.Equal(t, int64(0), cfg.Since) + assert.Equal(t, 0, cfg.Limit) + assert.Equal(t, TradeState(""), cfg.TradeState) + assert.Nil(t, cfg.ExtraParams) +} + +func TestApplyQueryOpts_AllOptions(t *testing.T) { + cfg := ApplyQueryOpts([]QueryOption{ + WithSince(1000), + WithLimit(50), + WithTradeState(TradeStateCompleted), + WithExtraParams(map[string]string{"k": "v"}), + }) + assert.Equal(t, int64(1000), cfg.Since) + assert.Equal(t, 50, cfg.Limit) + assert.Equal(t, TradeStateCompleted, cfg.TradeState) + assert.Equal(t, map[string]string{"k": "v"}, cfg.ExtraParams) +} + +func TestApplyQueryOpts_LaterOptionWins(t *testing.T) { + cfg := ApplyQueryOpts([]QueryOption{ + WithSince(1000), + WithSince(2000), + }) + assert.Equal(t, int64(2000), cfg.Since) +} + +// --------------------------------------------------------------------------- +// NormalizeItemName +// --------------------------------------------------------------------------- + +func TestNormalizeItemName(t *testing.T) { + tests := []struct { + input string + wantName string + wantExterior string + }{ + {"蝴蝶刀(★) | 澄澈之水 (久经沙场)", "蝴蝶刀(★) | 澄澈之水", "久经沙场"}, + {"AK-47 | Redline (Field-Tested)", "AK-47 | Redline", "Field-Tested"}, + {"M4A4 | 咆哮 (崭新出厂)", "M4A4 | 咆哮", "崭新出厂"}, + {"AWP | Dragon Lore (Factory New)", "AWP | Dragon Lore", "Factory New"}, + {"Knife (★) | Doppler (略有磨损)", "Knife (★) | Doppler", "略有磨损"}, + {"无磨损皮肤", "无磨损皮肤", ""}, + {"", "", ""}, + } + for _, tt := range tests { + name, ext := NormalizeItemName(tt.input) + assert.Equal(t, tt.wantName, name, "name mismatch for %q", tt.input) + assert.Equal(t, tt.wantExterior, ext, "exterior mismatch for %q", tt.input) + } +} + +// --------------------------------------------------------------------------- +// MockClient (generated mock smoke test) +// --------------------------------------------------------------------------- + +func TestMockClient_Verify(t *testing.T) { + m := NewMockClient(t) + m.EXPECT().Verify(mock.Anything).Return(nil) + + err := m.Verify(context.Background()) + assert.NoError(t, err) +} + +func TestMockClient_Verify_Error(t *testing.T) { + m := NewMockClient(t) + m.EXPECT().Verify(mock.Anything).Return(errors.New("auth failed")) + + err := m.Verify(context.Background()) + assert.EqualError(t, err, "auth failed") +} + +func TestMockClient_GetBalance(t *testing.T) { + m := NewMockClient(t) + expected := &Balance{Available: 100.0, Frozen: 50.0} + m.EXPECT().GetBalance(mock.Anything).Return(expected, nil) + + b, err := m.GetBalance(context.Background()) + require.NoError(t, err) + assert.Equal(t, 100.0, b.Available) + assert.Equal(t, 50.0, b.Frozen) +} + +func TestMockClient_GetBuyHistory_MatchOpts(t *testing.T) { + m := NewMockClient(t) + m.EXPECT().GetBuyHistory(mock.Anything, mock.MatchedBy(func(opts []QueryOption) bool { + cfg := ApplyQueryOpts(opts) + return cfg.Since == 42 && cfg.Limit == 10 + })).Return([]TradeRecord{{ExternalID: "t1"}}, nil) + + records, err := m.GetBuyHistory(context.Background(), WithSince(42), WithLimit(10)) + require.NoError(t, err) + assert.Len(t, records, 1) + assert.Equal(t, "t1", records[0].ExternalID) +} + +// --------------------------------------------------------------------------- +// FetchAllPages +// --------------------------------------------------------------------------- + +func TestFetchAllPages_Empty(t *testing.T) { + items, err := FetchAllPages(context.Background(), nopLogger(), "test", "buy", 0, 0, + func(_ context.Context, page int) ([]string, bool, error) { + return nil, false, nil + }, + ) + require.NoError(t, err) + assert.Empty(t, items) +} + +func TestFetchAllPages_SinglePage(t *testing.T) { + items, err := FetchAllPages(context.Background(), nopLogger(), "test", "buy", 0, 0, + func(_ context.Context, page int) ([]string, bool, error) { + if page == 1 { + return []string{"a", "b"}, false, nil + } + return nil, false, nil + }, + ) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, items) +} + +func TestFetchAllPages_MultiplePages(t *testing.T) { + pageCalls := 0 + items, err := FetchAllPages(context.Background(), nopLogger(), "t", "d", 0, 0, + func(_ context.Context, page int) ([]string, bool, error) { + pageCalls++ + if page <= 3 { + return []string{"x"}, true, nil + } + return nil, false, nil + }, + ) + require.NoError(t, err) + assert.Len(t, items, 3) + assert.Equal(t, 4, pageCalls) +} + +func TestFetchAllPages_Limit(t *testing.T) { + items, err := FetchAllPages(context.Background(), nopLogger(), "t", "d", 0, 2, + func(_ context.Context, page int) ([]string, bool, error) { + return []string{"a", "b", "c"}, true, nil + }, + ) + require.NoError(t, err) + assert.Len(t, items, 2) +} + +func TestFetchAllPages_Error(t *testing.T) { + sentinel := errors.New("boom") + items, err := FetchAllPages(context.Background(), nopLogger(), "t", "d", 0, 0, + func(_ context.Context, page int) ([]string, bool, error) { + return nil, false, sentinel + }, + ) + assert.ErrorContains(t, err, "boom") + assert.Nil(t, items) +} + +func TestFetchAllPages_ContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + items, err := FetchAllPages(ctx, nopLogger(), "t", "d", 0, 0, + func(_ context.Context, page int) ([]string, bool, error) { + return nil, true, nil + }, + ) + assert.ErrorIs(t, err, context.Canceled) + assert.Nil(t, items) +} + +// --------------------------------------------------------------------------- +// FetchByTimeWindows +// --------------------------------------------------------------------------- + +func TestFetchByTimeWindows_SingleWindow(t *testing.T) { + cfg := QueryConfig{Limit: 1} + items, err := FetchByTimeWindows(context.Background(), nopLogger(), "eco", "bill", cfg, 30, + func(_ context.Context, page int, _, _ time.Time) ([]string, bool, error) { + return []string{"a", "b"}, true, nil + }, + ) + require.NoError(t, err) + assert.Equal(t, []string{"a"}, items) +} + +func TestFetchByTimeWindows_ConsecutiveEmptyBreaks(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var calls int + _, _ = FetchByTimeWindows(ctx, nopLogger(), "eco", "bill", QueryConfig{}, 30, + func(_ context.Context, page int, _, _ time.Time) ([]string, bool, error) { + calls++ + return nil, false, nil + }, + ) + assert.GreaterOrEqual(t, calls, 12) +} + +func TestFetchByTimeWindows_SinceTime_Respected(t *testing.T) { + since := time.Now().Add(-1 * time.Hour).UnixMilli() + cfg := QueryConfig{Since: since} + var seenWindows []time.Time + _, _ = FetchByTimeWindows(context.Background(), nopLogger(), "eco", "bill", cfg, 30, + func(_ context.Context, page int, ws, _ time.Time) ([]string, bool, error) { + seenWindows = append(seenWindows, ws) + return nil, false, nil + }, + ) + assert.LessOrEqual(t, len(seenWindows), 3) + for _, ws := range seenWindows { + assert.False(t, ws.Before(time.UnixMilli(since)), "window start %v before since %v", ws, time.UnixMilli(since)) + } +} diff --git a/pkg/platform/eco/client.go b/pkg/platform/eco/client.go index 35975d7..21c68a7 100644 --- a/pkg/platform/eco/client.go +++ b/pkg/platform/eco/client.go @@ -46,6 +46,20 @@ func newWithParts(partnerID, privateKeyPEM string, logger *logfx.Logger) (*Clien }, nil } +// parseECO unmarshals an ecoResponse envelope and checks ResultCode. +func parseECO[T any](body []byte) (T, error) { + var result ecoResponse[T] + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + if result.ResultCode != "0" { + var zero T + return zero, fmt.Errorf("eco API error: code=%s msg=%s", result.ResultCode, result.ResultMsg) + } + return result.ResultData, nil +} + func (c *Client) Verify(ctx context.Context) error { c.Log.Info("eco: verifying") body, err := c.doRequest(ctx, "/Api/Merchant/GetTotalMoney", nil) @@ -54,13 +68,9 @@ func (c *Client) Verify(ctx context.Context) error { return fmt.Errorf("eco verify: %w", err) } - var result merchantMoneyResponse - if err := json.Unmarshal(body, &result); err != nil { + if _, err := parseECO[merchantMoneyModel](body); err != nil { return fmt.Errorf("eco verify: %w", err) } - if result.ResultCode != "0" { - return fmt.Errorf("eco verify: resultCode=%s", result.ResultCode) - } c.Log.Info("eco: verify ok") return nil } @@ -72,106 +82,106 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { return nil, fmt.Errorf("eco balance: %w", err) } - var result merchantMoneyResponse - if err := json.Unmarshal(body, &result); err != nil { + data, err := parseECO[merchantMoneyModel](body) + if err != nil { return nil, fmt.Errorf("eco balance: %w", err) } - if result.ResultCode != "0" { - return nil, fmt.Errorf("eco balance: resultCode=%s", result.ResultCode) - } return &platform.Balance{ - Available: result.ResultData.Money, - Purchase: result.ResultData.PurchaseMoney, - Frozen: result.ResultData.LockMoney, + Available: data.Money, + Purchase: data.PurchaseMoney, + Frozen: data.LockMoney, }, nil } -func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { +func (c *Client) GetBillHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.BillRecord, error) { cfg := platform.ApplyQueryOpts(opts) - c.Log.Info("eco: fetching buy history", "since", cfg.Since) - trades, err := c.fetchHistory(ctx, "buy", cfg, c.fetchBuyPage) + c.Log.Info("eco: fetching bill history", "since", cfg.Since) + + bills, err := platform.FetchByTimeWindows(ctx, c.Log, c.Name, "bill", cfg, apiMaxDays, + func(ctx context.Context, page int, windowStart, windowEnd time.Time) ([]platform.BillRecord, bool, error) { + return c.fetchBillPage(ctx, page, cfg.Since, windowStart, windowEnd) + }, + ) if err != nil { - return trades, err + return bills, err } - c.Log.Info("eco: buy history done", "total", len(trades)) - return trades, nil + c.Log.Info("eco: bill history done", "total", len(bills)) + return bills, nil } -func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { - cfg := platform.ApplyQueryOpts(opts) - c.Log.Info("eco: fetching sell history", "since", cfg.Since) - trades, err := c.fetchHistory(ctx, "sell", cfg, c.fetchSellPage) +func (c *Client) fetchBillPage(ctx context.Context, page int, since int64, windowStart, windowEnd time.Time) ([]platform.BillRecord, bool, error) { + body := map[string]any{ + "PageIndex": page, + "PageSize": 100, + "StartTime": windowStart.Format(dateFormat), + "EndTime": windowEnd.Format(dateFormat), + } + respBody, err := c.doRequest(ctx, "/Api/Merchant/GetFundFlow", body) if err != nil { - return trades, err + return nil, false, err } - c.Log.Info("eco: sell history done", "total", len(trades)) - return trades, nil -} -type pageFetchFn func(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string, windowStart, windowEnd time.Time) ([]platform.TradeRecord, bool, error) + c.Log.Debug("eco: fund flow raw response", "body", string(respBody)) -func (c *Client) fetchHistory( - ctx context.Context, - direction string, - cfg platform.QueryConfig, - fetchPage pageFetchFn, -) ([]platform.TradeRecord, error) { - var all []platform.TradeRecord - windowEnd := time.Now() - - var sinceTime time.Time - if cfg.Since > 0 { - sinceTime = time.UnixMilli(cfg.Since) + pages, err := parseECO[fundFlowPagesModel](respBody) + if err != nil { + return nil, false, err } - consecutiveEmpty := 0 - for { - windowStart := windowEnd.AddDate(0, 0, -apiMaxDays) - if !sinceTime.IsZero() && windowStart.Before(sinceTime) { - windowStart = sinceTime - } - if !windowEnd.After(windowStart) { - break - } - - remaining := cfg.Limit - if remaining > 0 { - remaining -= len(all) - if remaining <= 0 { - break - } - } + c.Log.Debug("eco: fund flow response parsed", "page", page, "totalRecord", pages.TotalRecord, "pageResultLen", len(pages.PageResult), "pageSize", pages.PageSize) - trades, err := platform.FetchAllPages(ctx, c.Log, c.Name, direction, 500*time.Millisecond, remaining, - func(ctx context.Context, page int) ([]platform.TradeRecord, bool, error) { - return fetchPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams, windowStart, windowEnd) - }, - ) + records := make([]platform.BillRecord, 0, len(pages.PageResult)) + finished := false + for _, item := range pages.PageResult { + rec, err := toBillRecord(item) if err != nil { - return all, err + c.Log.Warn("eco: bill item parse failed, skipping", "err", err) + continue } - all = append(all, trades...) - c.Log.Info("eco: time window done", "direction", direction, - "window", windowStart.Format(dateFormat)+"~"+windowEnd.Format(dateFormat), - "count", len(trades), "total", len(all)) - - if !sinceTime.IsZero() && !windowStart.After(sinceTime) { - break + if since > 0 && rec.AddTime < since { + finished = true + continue } + records = append(records, rec) + } - if len(trades) == 0 { - consecutiveEmpty++ - if consecutiveEmpty >= 12 { - break - } - } else { - consecutiveEmpty = 0 - } - windowEnd = windowStart - time.Sleep(500 * time.Millisecond) + c.Log.Debug("eco: fund flow processed", "page", page, "recordsAfterFilter", len(records), "finished", finished, "since", since) + + hasMore := !finished && len(pages.PageResult) == pages.PageSize + if pages.TotalRecord > 0 { + hasMore = page*pages.PageSize < pages.TotalRecord && !finished } + return records, hasMore, nil +} - return all, nil +func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { + cfg := platform.ApplyQueryOpts(opts) + c.Log.Info("eco: fetching buy history", "since", cfg.Since) + trades, err := platform.FetchByTimeWindows(ctx, c.Log, c.Name, "buy", cfg, apiMaxDays, + func(ctx context.Context, page int, windowStart, windowEnd time.Time) ([]platform.TradeRecord, bool, error) { + return c.fetchBuyPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams, windowStart, windowEnd) + }, + ) + if err != nil { + return trades, err + } + c.Log.Info("eco: buy history done", "total", len(trades)) + return trades, nil +} + +func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { + cfg := platform.ApplyQueryOpts(opts) + c.Log.Info("eco: fetching sell history", "since", cfg.Since) + trades, err := platform.FetchByTimeWindows(ctx, c.Log, c.Name, "sell", cfg, apiMaxDays, + func(ctx context.Context, page int, windowStart, windowEnd time.Time) ([]platform.TradeRecord, bool, error) { + return c.fetchSellPage(ctx, page, cfg.Since, cfg.TradeState, cfg.ExtraParams, windowStart, windowEnd) + }, + ) + if err != nil { + return trades, err + } + c.Log.Info("eco: sell history done", "total", len(trades)) + return trades, nil } func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeState platform.TradeState, extra map[string]string, windowStart, windowEnd time.Time) ([]platform.TradeRecord, bool, error) { @@ -196,19 +206,16 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS return nil, false, err } - var result buyerOrderListResponse - if err := json.Unmarshal(respBody, &result); err != nil { + pages, err := parseECO[buyerOrderPagesModel](respBody) + if err != nil { return nil, false, err } - if result.ResultCode != "0" { - return nil, false, fmt.Errorf("eco API error: code=%s msg=%s", result.ResultCode, result.ResultMsg) - } - c.Log.Debug("eco: buyer order list response", "totalRecord", result.ResultData.TotalRecord, "pageSize", result.ResultData.PageSize, "pageLen", len(result.ResultData.PageResult), "raw", string(respBody)) + c.Log.Debug("eco: buyer order list response", "totalRecord", pages.TotalRecord, "pageSize", pages.PageSize, "pageLen", len(pages.PageResult), "raw", string(respBody)) - trades := make([]platform.TradeRecord, 0, len(result.ResultData.PageResult)) + trades := make([]platform.TradeRecord, 0, len(pages.PageResult)) anyPastSince := false - for _, o := range result.ResultData.PageResult { + for _, o := range pages.PageResult { tradeAt := parseTradeAt(o.CreateOrderTime) if tradeAt < since { anyPastSince = true @@ -219,7 +226,7 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS c.enrichBuyPage(ctx, trades) - hasMore := page*100 < result.ResultData.TotalRecord + hasMore := page*100 < pages.TotalRecord if anyPastSince { hasMore = false } @@ -249,19 +256,16 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade return nil, false, err } - var result sellerOrderListResponse - if err := json.Unmarshal(respBody, &result); err != nil { + pages, err := parseECO[sellerOrderPagesModel](respBody) + if err != nil { return nil, false, err } - if result.ResultCode != "0" { - return nil, false, fmt.Errorf("eco API error: code=%s msg=%s", result.ResultCode, result.ResultMsg) - } - c.Log.Debug("eco: seller order list response", "totalRecord", result.ResultData.TotalRecord, "pageLen", len(result.ResultData.PageResult), "raw", string(respBody)) + c.Log.Debug("eco: seller order list response", "totalRecord", pages.TotalRecord, "pageLen", len(pages.PageResult), "raw", string(respBody)) - trades := make([]platform.TradeRecord, 0, len(result.ResultData.PageResult)) + trades := make([]platform.TradeRecord, 0, len(pages.PageResult)) anyPastSince := false - for _, o := range result.ResultData.PageResult { + for _, o := range pages.PageResult { tradeAt := parseTradeAt(o.CreateOrderTime) if tradeAt < since { anyPastSince = true @@ -272,7 +276,7 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade c.enrichSellPage(ctx, trades) - hasMore := page*100 < result.ResultData.TotalRecord + hasMore := page*100 < pages.TotalRecord if anyPastSince { hasMore = false } @@ -349,7 +353,7 @@ func (c *Client) fetchBuyDetail(ctx context.Context, orderNum string) (orderDeta if err != nil { return orderDetailModel{}, err } - return parseOrderDetailResponse(respBody) + return parseECO[orderDetailModel](respBody) } func (c *Client) fetchSellDetail(ctx context.Context, orderNum string) (orderDetailModel, error) { @@ -358,18 +362,7 @@ func (c *Client) fetchSellDetail(ctx context.Context, orderNum string) (orderDet if err != nil { return orderDetailModel{}, err } - return parseOrderDetailResponse(respBody) -} - -func parseOrderDetailResponse(body []byte) (orderDetailModel, error) { - var result orderDetailResponse - if err := json.Unmarshal(body, &result); err != nil { - return orderDetailModel{}, err - } - if result.ResultCode != "0" { - return orderDetailModel{}, fmt.Errorf("resultCode=%s msg=%s", result.ResultCode, result.ResultMsg) - } - return result.ResultData, nil + return parseECO[orderDetailModel](respBody) } func (c *Client) headers() http.Header { diff --git a/pkg/platform/eco/convert.go b/pkg/platform/eco/convert.go index 1d379c5..4000501 100644 --- a/pkg/platform/eco/convert.go +++ b/pkg/platform/eco/convert.go @@ -74,6 +74,61 @@ func toSellTradeFromListItem(o sellerOrderModel) platform.TradeRecord { } } +func ecoFundTypeToInternal(typeName string) int { + switch typeName { + case "充值": + return model.BillTypeRecharge + case "提现": + return model.BillTypeWithdraw + case "出售", "待结算": + return model.BillTypeSell + case "购买": + return model.BillTypePurchase + case "出租": + return model.BillTypeRentalIncome + case "租赁": + return model.BillTypeRentalFee + case "发布求购": + return model.BillTypeRechargForPurchaseAccount + case "还价": + return model.BillTypeRefund + default: + return model.BillTypeOther + } +} + +func toBillRecord(item fundFlowItemModel) (platform.BillRecord, error) { + money := yuanToFen(item.Amount) + if item.AfterAmount < item.LastAmount { + money = -money + } + + t, err := time.ParseInLocation("2006-01-02T15:04:05", item.CreateTime, cst) + if err != nil { + t, err = time.ParseInLocation("2006-01-02 15:04:05", item.CreateTime, cst) + if err != nil { + t, err = time.ParseInLocation("2006-01-02T15:04:05Z", item.CreateTime, cst) + if err != nil { + return platform.BillRecord{}, fmt.Errorf("parse CreateTime %q: %w", item.CreateTime, err) + } + } + } + + typeID := ecoFundTypeToInternal(item.Type) + typeName := model.BillTypeName(typeID) + if typeName == "" { + typeName = item.Type + } + + return platform.BillRecord{ + TypeName: typeName, + TypeID: typeID, + ThisMoney: money, + OrderNo: item.OrderID, + AddTime: t.UnixMilli(), + }, nil +} + func toCS2Item(ap assetPreviewModel) model.CS2Item { name, exterior := platform.NormalizeItemName(ap.GoodsName) pw, _ := strconv.ParseFloat(ap.PaintWear, 64) diff --git a/pkg/platform/eco/model.go b/pkg/platform/eco/model.go index 49f2acd..2b46859 100644 --- a/pkg/platform/eco/model.go +++ b/pkg/platform/eco/model.go @@ -29,6 +29,14 @@ const ( TradeTypeBoxOpen = 13 // 在线开箱 ) +// --- Common --- + +type ecoResponse[T any] struct { + ResultCode string `json:"ResultCode"` + ResultMsg string `json:"ResultMsg"` + ResultData T `json:"ResultData"` +} + // --- Balance --- type merchantMoneyModel struct { @@ -39,12 +47,6 @@ type merchantMoneyModel struct { PurchaseFrozenMoney float64 `json:"PurchaseFrozenMoney"` } -type merchantMoneyResponse struct { - ResultCode string `json:"ResultCode"` - ResultMsg string `json:"ResultMsg"` - ResultData merchantMoneyModel `json:"ResultData"` -} - // --- Buy Orders --- type buyerOrderModel struct { @@ -77,12 +79,6 @@ type buyerOrderPagesModel struct { PageResult []buyerOrderModel `json:"PageResult"` } -type buyerOrderListResponse struct { - ResultCode string `json:"ResultCode"` - ResultMsg string `json:"ResultMsg"` - ResultData buyerOrderPagesModel `json:"ResultData"` -} - // --- Sell Orders --- type sellerOrderModel struct { @@ -118,12 +114,6 @@ type sellerOrderPagesModel struct { PageResult []sellerOrderModel `json:"PageResult"` } -type sellerOrderListResponse struct { - ResultCode string `json:"ResultCode"` - ResultMsg string `json:"ResultMsg"` - ResultData sellerOrderPagesModel `json:"ResultData"` -} - // --- Asset Preview (from detail API) --- type assetPreviewModel struct { @@ -202,8 +192,21 @@ type orderDetailModel struct { AssetPreviewModel assetPreviewModel `json:"AssetPreviewModel"` } -type orderDetailResponse struct { - ResultCode string `json:"ResultCode"` - ResultMsg string `json:"ResultMsg"` - ResultData orderDetailModel `json:"ResultData"` +// --- Fund Flow --- + +type fundFlowItemModel struct { + OrderID string `json:"OrderID"` + Amount float64 `json:"Amount"` + Type string `json:"Type"` + LastAmount float64 `json:"LastAmount"` + AfterAmount float64 `json:"AfterAmount"` + CreateTime string `json:"CreateTime"` + FounType string `json:"FounType"` +} + +type fundFlowPagesModel struct { + PageIndex int `json:"PageIndex"` + PageSize int `json:"PageSize"` + TotalRecord int `json:"TotalRecord"` + PageResult []fundFlowItemModel `json:"PageResult"` } diff --git a/pkg/platform/fake_client.go b/pkg/platform/fake_client.go deleted file mode 100644 index 2181c86..0000000 --- a/pkg/platform/fake_client.go +++ /dev/null @@ -1,36 +0,0 @@ -package platform - -import ( - "context" - "fmt" -) - -// FakeClient implements Client with configurable responses for testing. -type FakeClient struct { - VerifyErr error - BuyHistory []TradeRecord - SellHistory []TradeRecord - BalanceResp *Balance -} - -func (f *FakeClient) Verify(_ context.Context) error { - if f.VerifyErr != nil { - return fmt.Errorf("fake verify: %w", f.VerifyErr) - } - return nil -} - -func (f *FakeClient) GetBuyHistory(_ context.Context, _ ...QueryOption) ([]TradeRecord, error) { - return f.BuyHistory, nil -} - -func (f *FakeClient) GetSellHistory(_ context.Context, _ ...QueryOption) ([]TradeRecord, error) { - return f.SellHistory, nil -} - -func (f *FakeClient) GetBalance(_ context.Context) (*Balance, error) { - if f.BalanceResp != nil { - return f.BalanceResp, nil - } - return &Balance{}, nil -} diff --git a/pkg/platform/igxe/client.go b/pkg/platform/igxe/client.go index 2f8608e..dc41e55 100644 --- a/pkg/platform/igxe/client.go +++ b/pkg/platform/igxe/client.go @@ -49,6 +49,20 @@ func newWithParts(partnerID, privateKeyPEM string, logger *logfx.Logger) (*Clien }, nil } +// parseIgxe unmarshals an igxeResponse envelope and checks ResultCode. +func parseIgxe[T any](body []byte) (T, error) { + var result igxeResponse[T] + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + if result.ResultCode != "0" { + var zero T + return zero, fmt.Errorf("igxe API error: code=%s msg=%s", result.ResultCode, result.ResultMsg) + } + return result.ResultData, nil +} + func (c *Client) Verify(ctx context.Context) error { c.Log.Info("igxe: verifying") _, body, err := c.doRequest(ctx, "POST", "/Api/Merchant/GetTotalMoney", nil, nil) @@ -57,13 +71,9 @@ func (c *Client) Verify(ctx context.Context) error { return fmt.Errorf("igxe verify: %w", err) } - var result totalMoneyResponse - if err := json.Unmarshal(body, &result); err != nil { + if _, err := parseIgxe[totalMoneyData](body); err != nil { return fmt.Errorf("igxe verify: %w", err) } - if result.ResultCode != "0" { - return fmt.Errorf("igxe verify: resultCode=%s", result.ResultCode) - } c.Log.Info("igxe: verify ok") return nil } @@ -75,19 +85,20 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { return nil, fmt.Errorf("igxe balance: %w", err) } - var result totalMoneyResponse - if err := json.Unmarshal(body, &result); err != nil { + data, err := parseIgxe[totalMoneyData](body) + if err != nil { return nil, fmt.Errorf("igxe balance: %w", err) } - if result.ResultCode != "0" { - return nil, fmt.Errorf("igxe balance: resultCode=%s", result.ResultCode) - } return &platform.Balance{ - Available: result.ResultData.Money, + Available: data.Money, Purchase: 0, }, nil } +func (c *Client) GetBillHistory(_ context.Context, _ ...platform.QueryOption) ([]platform.BillRecord, error) { + return nil, nil +} + func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { return []platform.TradeRecord{}, nil } @@ -123,17 +134,14 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade return nil, false, err } - var result sellerOrderListResponse - if err := json.Unmarshal(respBody, &result); err != nil { + data, err := parseIgxe[sellerOrderListData](respBody) + if err != nil { return nil, false, err } - if result.ResultCode != "0" { - return nil, false, fmt.Errorf("igxe API error: resultCode=%s msg=%s", result.ResultCode, result.ResultMsg) - } - trades := make([]platform.TradeRecord, 0, len(result.ResultData.PageResult)) + trades := make([]platform.TradeRecord, 0, len(data.PageResult)) anyAfterSince := false - for _, item := range result.ResultData.PageResult { + for _, item := range data.PageResult { tradeAt := c.parseCreateDate(item.CreateDate) if tradeAt >= since { anyAfterSince = true @@ -144,10 +152,10 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade trades = append(trades, toSellTrade(item, tradeAt)) } - if len(result.ResultData.PageResult) > 0 && !anyAfterSince { + if len(data.PageResult) > 0 && !anyAfterSince { return trades, false, nil } - hasMore := len(result.ResultData.PageResult) >= 100 + hasMore := len(data.PageResult) >= 100 return trades, hasMore, nil } diff --git a/pkg/platform/igxe/model.go b/pkg/platform/igxe/model.go index b7400dd..a9f2b2b 100644 --- a/pkg/platform/igxe/model.go +++ b/pkg/platform/igxe/model.go @@ -1,17 +1,15 @@ package igxe -type totalMoneyResponse struct { +// igxeResponse is a generic envelope for IGXE API responses. +type igxeResponse[T any] struct { ResultCode string `json:"ResultCode"` - ResultData struct { - Money float64 `json:"Money"` - UserName string `json:"UserName"` - } `json:"ResultData"` + ResultMsg string `json:"ResultMsg"` + ResultData T `json:"ResultData"` } -type sellerOrderListResponse struct { - ResultCode string `json:"ResultCode"` - ResultMsg string `json:"ResultMsg"` - ResultData sellerOrderListData `json:"ResultData"` +type totalMoneyData struct { + Money float64 `json:"Money"` + UserName string `json:"UserName"` } type sellerOrderListData struct { diff --git a/pkg/platform/utils.go b/pkg/platform/utils.go index ae1adb7..38141de 100644 --- a/pkg/platform/utils.go +++ b/pkg/platform/utils.go @@ -51,15 +51,15 @@ func RandomUA() string { // FetchAllPages calls fetchFn for each page until exhausted or ctx cancelled. // When limit > 0, pagination stops early once limit records are collected. -func FetchAllPages( +func FetchAllPages[T any]( ctx context.Context, log *logfx.Logger, name, direction string, pageSleep time.Duration, limit int, - fetchFn func(ctx context.Context, page int) (items []TradeRecord, hasMore bool, err error), -) ([]TradeRecord, error) { - var all []TradeRecord + fetchFn func(ctx context.Context, page int) (items []T, hasMore bool, err error), +) ([]T, error) { + var all []T page := 1 for { if err := ctx.Err(); err != nil { @@ -87,3 +87,72 @@ func FetchAllPages( } } } + +// FetchByTimeWindows paginates through sliding time windows (e.g. 30-day API limits), +// calling FetchAllPages within each window. +func FetchByTimeWindows[T any]( + ctx context.Context, + log *logfx.Logger, + name, direction string, + cfg QueryConfig, + maxWindowDays int, + pageFn func(ctx context.Context, page int, windowStart, windowEnd time.Time) ([]T, bool, error), +) ([]T, error) { + var all []T + windowEnd := time.Now() + consecutiveEmpty := 0 + + var sinceTime time.Time + if cfg.Since > 0 { + sinceTime = time.UnixMilli(cfg.Since) + } + + for { + windowStart := windowEnd.AddDate(0, 0, -maxWindowDays) + if !sinceTime.IsZero() && windowStart.Before(sinceTime) { + windowStart = sinceTime + } + if !windowEnd.After(windowStart) { + break + } + + remaining := cfg.Limit + if remaining > 0 { + remaining -= len(all) + if remaining <= 0 { + break + } + } + + items, err := FetchAllPages(ctx, log, name, direction, 500*time.Millisecond, remaining, + func(ctx context.Context, page int) ([]T, bool, error) { + return pageFn(ctx, page, windowStart, windowEnd) + }, + ) + if err != nil { + return all, err + } + all = append(all, items...) + + log.Info(name+": time window done", "direction", direction, + "window", windowStart.Format("2006-01-02 15:04")+"~"+windowEnd.Format("2006-01-02 15:04"), + "count", len(items), "total", len(all)) + + if !sinceTime.IsZero() && !windowStart.After(sinceTime) { + break + } + + if len(items) == 0 { + consecutiveEmpty++ + if consecutiveEmpty >= 12 { + break + } + } else { + consecutiveEmpty = 0 + } + windowEnd = windowStart + time.Sleep(500 * time.Millisecond) + } + + return all, nil +} diff --git a/pkg/platform/youpin/client.go b/pkg/platform/youpin/client.go index 0f08625..0711890 100644 --- a/pkg/platform/youpin/client.go +++ b/pkg/platform/youpin/client.go @@ -191,21 +191,17 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS return nil, false, err } - var result youpinBuyPageResponse - if err := json.Unmarshal(respBody, &result); err != nil { + data, err := parseYoupin[youpinBuyPageData](respBody) + if err != nil { c.Log.Warn("youpin buy page failed", "page", page, "err", err) return nil, false, err } - if result.Code != 0 { - c.Log.Warn("youpin buy page: API error", "code", result.Code) - return nil, false, fmt.Errorf("youpin API error: code=%d", result.Code) - } - c.Log.Debug("youpin buy page", "page", page, "orders", len(result.Data.OrderList), "totalCount", result.Data.TotalCount) + c.Log.Debug("youpin buy page", "page", page, "orders", len(data.OrderList), "totalCount", data.TotalCount) - trades := make([]platform.TradeRecord, 0, len(result.Data.OrderList)) + trades := make([]platform.TradeRecord, 0, len(data.OrderList)) finished := false - for _, o := range result.Data.OrderList { + for _, o := range data.OrderList { if tradeState == platform.TradeStateCompleted && o.OrderStatusName != "已完成" { continue } @@ -227,14 +223,11 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS } } - if len(result.Data.OrderList) == 0 || finished { + if len(data.OrderList) == 0 || finished { return trades, false, nil } - // API may return null total; fall back to page-full heuristic. - if result.Data.TotalCount > 0 { - return trades, page*DefaultPageSize < result.Data.TotalCount, nil - } - return trades, len(result.Data.OrderList) == DefaultPageSize, nil + // API may return null total + return trades, len(data.OrderList) == DefaultPageSize, nil } func (c *Client) fetchBuyBatch(ctx context.Context, orderNo string, buyerUserID int64, finishTime int64) ([]platform.TradeRecord, error) { @@ -346,21 +339,17 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade return nil, false, err } - var result youpinSellPageResponse - if err := json.Unmarshal(respBody, &result); err != nil { + data, err := parseYoupin[youpinSellPageData](respBody) + if err != nil { c.Log.Warn("youpin sell page failed", "page", page, "err", err) return nil, false, err } - if result.Code != 0 { - c.Log.Warn("youpin sell page: API error", "code", result.Code) - return nil, false, fmt.Errorf("youpin API error: code=%d", result.Code) - } - c.Log.Debug("youpin sell page", "page", page, "orders", len(result.Data.OrderList), "totalCount", result.Data.TotalCount) + c.Log.Debug("youpin sell page", "page", page, "orders", len(data.OrderList), "totalCount", data.TotalCount) - trades := make([]platform.TradeRecord, 0, len(result.Data.OrderList)) + trades := make([]platform.TradeRecord, 0, len(data.OrderList)) finished := false - for _, o := range result.Data.OrderList { + for _, o := range data.OrderList { if tradeState == platform.TradeStateCompleted && o.OrderStatusName != "已完成" { continue } @@ -371,11 +360,11 @@ func (c *Client) fetchSellPage(ctx context.Context, page int, since int64, trade trades = append(trades, toSellTrade(o)) } - if len(result.Data.OrderList) == 0 || finished { + if len(data.OrderList) == 0 || finished { return trades, false, nil } // API may return null total - return trades, len(result.Data.OrderList) == DefaultPageSize, nil + return trades, len(data.OrderList) == DefaultPageSize, nil } func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { @@ -420,6 +409,73 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { }, nil } +func (c *Client) GetBillHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.BillRecord, error) { + c.registerDevice() + cfg := platform.ApplyQueryOpts(opts) + c.Log.Debug("youpin: fetching bill history", "since", cfg.Since, "page", cfg.Page, "pageSize", cfg.PageSize) + + // Single-page mode + if cfg.Page > 0 { + records, _, err := c.fetchBillPage(ctx, cfg.Page, cfg.Since, cfg.PageSize) + return records, err + } + + bills, err := platform.FetchAllPages(ctx, c.Log, c.Name, "bill", 1*time.Second, cfg.Limit, + func(ctx context.Context, page int) ([]platform.BillRecord, bool, error) { + return c.fetchBillPage(ctx, page, cfg.Since, cfg.PageSize) + }, + ) + if err != nil { + return bills, err + } + c.Log.Info("youpin: bill history done", "total", len(bills)) + return bills, nil +} + +func (c *Client) fetchBillPage(ctx context.Context, page int, since int64, pageSize int) ([]platform.BillRecord, bool, error) { + if pageSize <= 0 { + pageSize = DefaultPageSize + } + body := map[string]any{ + "pageIndex": page, + "pageSize": pageSize, + "Sessionid": c.deviceID, + } + _, respBody, err := c.doRequest(ctx, "POST", "/api/youpin/bff/payment/v1/user/userAssets/query/page/v2", nil, body) + if err != nil { + return nil, false, err + } + + data, err := parseYoupin[youpinBillPageData](respBody) + if err != nil { + c.Log.Warn("youpin bill page failed", "page", page, "err", err, "body", string(respBody)) + return nil, false, err + } + + c.Log.Debug("youpin bill page", "page", page, "items", len(data.DataList), "total", data.Total) + + records := make([]platform.BillRecord, 0, len(data.DataList)) + finished := false + for _, item := range data.DataList { + rec, err := toBillRecord(item) + if err != nil { + c.Log.Warn("youpin: bill item parse failed, skipping", "err", err) + continue + } + if rec.AddTime < since { + finished = true + continue + } + records = append(records, rec) + } + + if len(data.DataList) == 0 || finished { + return records, false, nil + } + // API may return null total + return records, len(data.DataList) == pageSize, nil +} + // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- @@ -476,6 +532,20 @@ func (c *Client) headers() http.Header { return h } +// parseYoupin unmarshals a youpinResponse envelope and checks Code. +func parseYoupin[T any](body []byte) (T, error) { + var result youpinResponse[T] + if err := json.Unmarshal(body, &result); err != nil { + var zero T + return zero, err + } + if result.Code != 0 { + var zero T + return zero, fmt.Errorf("youpin API error: code=%d msg=%s", result.Code, result.Msg) + } + return result.Data, nil +} + // checkAPIError checks for known YouPin error codes in the response. func (c *Client) checkAPIError(body []byte) error { var resp struct { diff --git a/pkg/platform/youpin/convert.go b/pkg/platform/youpin/convert.go index e871f2a..dc42387 100644 --- a/pkg/platform/youpin/convert.go +++ b/pkg/platform/youpin/convert.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/platform" @@ -96,3 +97,55 @@ func toSellTrade(o youpinSellOrder) platform.TradeRecord { TradeAt: o.FinishOrderTime, } } + +// youpinTypeToInternal maps YouPin typeId to internal bill type constants. +func youpinTypeToInternal(typeID int) int { + switch typeID { + case 1: + return model.BillTypeRecharge + // 2: 提现; 44: 求购账户提现 + case 2, 44: + return model.BillTypeWithdraw + case 3: + return model.BillTypePurchase + case 4: + return model.BillTypeRefund + case 5: + return model.BillTypeSell + case 16: + return model.BillTypeRentalIncome + case 25: + return model.BillTypeRenewalRental + case 43: + return model.BillTypeRechargForPurchaseAccount + case 23: + return model.BillTypeWithdrawRefund + case 187: + return model.BillTypeRentalFee + default: + return model.BillTypeOther + } +} + +func toBillRecord(item youpinBillItem) (platform.BillRecord, error) { + money := parseYoupinPrice(json.Number(item.ThisMoney)) + t, err := time.ParseInLocation("2006-01-02 15:04:05", item.AddTime, time.Local) + if err != nil { + return platform.BillRecord{}, fmt.Errorf("parse addTime %q: %w", item.AddTime, err) + } + addTimeMs := t.UnixMilli() + + typeID := youpinTypeToInternal(item.TypeID) + typeName := model.BillTypeName(typeID) + if typeName == "" { + typeName = item.TypeName + } + + return platform.BillRecord{ + TypeName: typeName, + TypeID: typeID, + ThisMoney: money, + OrderNo: item.OrderNo, + AddTime: addTimeMs, + }, nil +} diff --git a/pkg/platform/youpin/model.go b/pkg/platform/youpin/model.go index b1377b3..5647ab0 100644 --- a/pkg/platform/youpin/model.go +++ b/pkg/platform/youpin/model.go @@ -2,6 +2,13 @@ package youpin import "encoding/json" +// youpinResponse is a generic envelope for YouPin API responses. +type youpinResponse[T any] struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data T `json:"data"` +} + type youpinBuyProduct struct { AssertID int64 `json:"assertId"` CommodityID int64 `json:"commodityId"` @@ -29,13 +36,10 @@ type youpinBuyOrder struct { ProductList []youpinBuyProduct `json:"productDetailList"` } -type youpinBuyPageResponse struct { - Code int `json:"code"` - Data struct { - OrderList []youpinBuyOrder `json:"orderList"` - TotalCount int `json:"total"` - OrderRevert any `json:"orderRevertInfo"` - } `json:"data"` +type youpinBuyPageData struct { + OrderList []youpinBuyOrder `json:"orderList"` + TotalCount int `json:"total"` + OrderRevert any `json:"orderRevertInfo"` } type youpinSellOrder struct { @@ -63,13 +67,10 @@ type youpinSellOrder struct { } `json:"productDetail"` } -type youpinSellPageResponse struct { - Code int `json:"code"` - Data struct { - OrderList []youpinSellOrder `json:"orderList"` - TotalCount int `json:"total"` - OrderRevert any `json:"orderRevertInfo"` - } `json:"data"` +type youpinSellPageData struct { + OrderList []youpinSellOrder `json:"orderList"` + TotalCount int `json:"total"` + OrderRevert any `json:"orderRevertInfo"` } // --- Balance --- @@ -119,3 +120,18 @@ type youpinBalanceListItem struct { Type int `json:"type"` BalanceType int `json:"balanceType"` } + +// --- Bill / Fund Flow --- + +type youpinBillItem struct { + TypeID int `json:"typeId"` + TypeName string `json:"typeName"` + ThisMoney string `json:"thisMoney"` // 元, e.g. "-426.00" + OrderNo string `json:"orderNo"` + AddTime string `json:"addTime"` // "2026-05-17 11:49:30" +} + +type youpinBillPageData struct { + Total int `json:"total"` + DataList []youpinBillItem `json:"dataList"` +} diff --git a/pkg/service/bill/service.go b/pkg/service/bill/service.go new file mode 100644 index 0000000..86097a1 --- /dev/null +++ b/pkg/service/bill/service.go @@ -0,0 +1,100 @@ +package bill + +import ( + "go.uber.org/fx" + + "github.com/CsJsss/CS2Ledger/pkg/model" + "github.com/CsJsss/CS2Ledger/pkg/orm" + "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" +) + +type BillFilter struct { + TypeID int + Platform string + StartTime int64 + EndTime int64 +} + +type BillInterface interface { + List(accountID uint, page, pageSize int, f BillFilter) (*PaginatedBills, error) + SumRentalIncome(accountID uint) (int64, error) + ChartData(accountID uint, f BillFilter) ([]orm.DailyBillSummary, error) +} + +type PaginatedBills struct { + Records []model.BillRecord `json:"records"` + TotalCount int64 `json:"totalCount"` +} + +type service struct { + log *logfx.Logger + orm orm.ORMInterface +} + +func NewService(log *logfx.Logger, orm orm.ORMInterface) *service { + return &service{log: log, orm: orm} +} + +func (s *service) SumRentalIncome(accountID uint) (int64, error) { + income, err := s.orm.SumBillsByTypes(accountID, []int{ + model.BillTypeRentalIncome, + model.BillTypeRenewalRental, + }) + if err != nil { + return 0, err + } + fee, err := s.orm.SumBillsByTypes(accountID, []int{ + model.BillTypeRentalFee, + }) + if err != nil { + return 0, err + } + return income + fee, nil +} + +func (s *service) ChartData(accountID uint, f BillFilter) ([]orm.DailyBillSummary, error) { + return s.orm.SumBillByDay(accountID, orm.BillFilter{ + TypeID: f.TypeID, + Platform: f.Platform, + StartTime: f.StartTime, + EndTime: f.EndTime, + }) +} + +func (s *service) List(accountID uint, page, pageSize int, f BillFilter) (*PaginatedBills, error) { + offset := (page - 1) * pageSize + of := orm.BillFilter{ + TypeID: f.TypeID, + Platform: f.Platform, + StartTime: f.StartTime, + EndTime: f.EndTime, + } + + var records []model.BillRecord + var total int64 + var err error + + if accountID == 0 { + records, err = s.orm.ListAllBills(pageSize, offset, of) + total, _ = s.orm.CountAllBills(of) + } else { + records, err = s.orm.ListBillsByAccount(accountID, pageSize, offset, of) + total, _ = s.orm.CountBillsByAccount(accountID, of) + } + if err != nil { + return nil, err + } + + return &PaginatedBills{ + Records: records, + TotalCount: total, + }, nil +} + +var Module = fx.Module("bill", + logfx.WithComponent("bill"), + fx.Provide( + NewService, + fx.Annotate(func(s *service) BillInterface { return s }, fx.As(new(BillInterface))), + ), +) diff --git a/pkg/service/service.go b/pkg/service/service.go index b596257..516b644 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -4,6 +4,7 @@ import ( "go.uber.org/fx" "github.com/CsJsss/CS2Ledger/pkg/service/account" + "github.com/CsJsss/CS2Ledger/pkg/service/bill" "github.com/CsJsss/CS2Ledger/pkg/service/inventory" "github.com/CsJsss/CS2Ledger/pkg/service/market" "github.com/CsJsss/CS2Ledger/pkg/service/pnl" @@ -14,6 +15,7 @@ import ( type Service struct { acc account.AccountInterface + b bill.BillInterface trd trade.TradeInterface inv inventory.InventoryInterface p pnl.PnlInterface @@ -24,6 +26,7 @@ type Service struct { func New( acc account.AccountInterface, + b bill.BillInterface, trd trade.TradeInterface, inv inventory.InventoryInterface, p pnl.PnlInterface, @@ -33,6 +36,7 @@ func New( ) *Service { s := &Service{ acc: acc, + b: b, trd: trd, inv: inv, p: p, @@ -48,6 +52,7 @@ func New( } func (s *Service) Account() account.AccountInterface { return s.acc } +func (s *Service) Bill() bill.BillInterface { return s.b } func (s *Service) Trade() trade.TradeInterface { return s.trd } func (s *Service) Inventory() inventory.InventoryInterface { return s.inv } func (s *Service) Pnl() pnl.PnlInterface { return s.p } diff --git a/pkg/service/sync/engine.go b/pkg/service/sync/engine.go index ec07e57..30e90f2 100644 --- a/pkg/service/sync/engine.go +++ b/pkg/service/sync/engine.go @@ -65,10 +65,11 @@ func (e *Engine) SyncAccount(accountID uint) (*SyncResult, error) { return nil, err } - buys, sells, balance, warnings := e.fetchData(client, acc.LastSyncAt) + buys, sells, balance, billHistory, warnings := e.fetchData(client, acc.LastSyncAt, acc.BillLastSyncAt) result := &SyncResult{Warnings: warnings} maxTradeMs := maxTradeAt(buys, sells) + maxBillMs := maxBillAt(billHistory) buys = aggregateBulkTrades(buys, acc.Platform, model.DirectionBuy) sells = aggregateBulkTrades(sells, acc.Platform, model.DirectionSell) @@ -77,13 +78,32 @@ func (e *Engine) SyncAccount(accountID uint) (*SyncResult, error) { result.NewTrades = e.persistTrades(accountID, acc.Platform, buys, sells) + persistedBills := 0 + for _, b := range billHistory { + rec := &model.BillRecord{ + AccountID: accountID, + Platform: acc.Platform, + TypeID: b.TypeID, + TypeName: b.TypeName, + ThisMoney: b.ThisMoney, + OrderNo: b.OrderNo, + AddTime: b.AddTime, + } + if err := e.orm.CreateBill(rec); err != nil { + e.log.Warn("create bill failed", "order_no", b.OrderNo, "err", err) + continue + } + persistedBills++ + } + e.log.Debug("bills persisted", "count", persistedBills) + e.mu.Lock() matchCount, matchErr := e.pnlSvc.RunMatching() e.mu.Unlock() result.NewPnl = matchCount e.log.Debug("global matching completed", "matched", matchCount, "err", matchErr) - e.updateAccountMeta(accountID, balance, maxTradeMs, len(warnings) > 0) + e.updateAccountMeta(accountID, balance, maxTradeMs, maxBillMs, len(warnings) > 0) e.log.Info("completed", "new_trades", result.NewTrades, @@ -123,30 +143,44 @@ func (e *Engine) createAndVerify(acc *model.Account) (platform.Client, error) { return client, nil } -// fetchData concurrently pulls buy history, sell history, and balance. -func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( - []platform.TradeRecord, []platform.TradeRecord, *platform.Balance, []string, +// fetchData concurrently pulls buy history, sell history, balance, and bill history. +func (e *Engine) fetchData(client platform.Client, lastSyncAt, billLastSyncAt *int64) ( + []platform.TradeRecord, []platform.TradeRecord, *platform.Balance, []platform.BillRecord, []string, ) { - since := int64(0) + tradeSince := int64(0) + tradeSinceSec := int64(0) if lastSyncAt != nil { - since = *lastSyncAt * 1000 // DB stores seconds, platform interface wants ms + tradeSinceSec = *lastSyncAt + tradeSince = *lastSyncAt * 1000 // DB stores seconds, platform interface wants ms + } + + billSince := int64(0) + billSinceSec := int64(0) + if billLastSyncAt != nil { + billSinceSec = *billLastSyncAt + billSince = *billLastSyncAt * 1000 } - e.log.Debug("fetching data", "since", utils.SecondsToDateTime(since, time.DateTime)) + + e.log.Debug("fetching data", + "trade_since", utils.SecondsToDateTime(tradeSinceSec, time.DateTime), + "bill_since", utils.SecondsToDateTime(billSinceSec, time.DateTime), + ) var ( buys, sells []platform.TradeRecord balance *platform.Balance + billHistory []platform.BillRecord mu sync.Mutex warnings []string wg sync.WaitGroup ) ctx := context.Background() - wg.Add(3) + wg.Add(4) go func() { defer wg.Done() - b, err := client.GetBuyHistory(ctx, platform.WithSince(since), platform.WithTradeState(platform.TradeStateCompleted)) + b, err := client.GetBuyHistory(ctx, platform.WithSince(tradeSince), platform.WithTradeState(platform.TradeStateCompleted)) mu.Lock() if err != nil { warnings = append(warnings, fmt.Sprintf("buy history: %v", err)) @@ -158,7 +192,7 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( go func() { defer wg.Done() - s, err := client.GetSellHistory(ctx, platform.WithSince(since), platform.WithTradeState(platform.TradeStateCompleted)) + s, err := client.GetSellHistory(ctx, platform.WithSince(tradeSince), platform.WithTradeState(platform.TradeStateCompleted)) mu.Lock() if err != nil { warnings = append(warnings, fmt.Sprintf("sell history: %v", err)) @@ -180,8 +214,20 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( mu.Unlock() }() + go func() { + defer wg.Done() + b, err := client.GetBillHistory(ctx, platform.WithSince(billSince)) + mu.Lock() + if err != nil { + warnings = append(warnings, fmt.Sprintf("bill history: %v", err)) + } + e.log.Debug("bill history fetched", "count", len(b), "err", err) + billHistory = b + mu.Unlock() + }() + wg.Wait() - return buys, sells, balance, warnings + return buys, sells, balance, billHistory, warnings } // persistTrades converts platform records to models and saves them. @@ -217,8 +263,8 @@ func (e *Engine) persistTrades(accountID uint, source string, buys, sells []plat return count } -// updateAccountMeta persists balance and sync timestamp. -func (e *Engine) updateAccountMeta(accountID uint, balance *platform.Balance, maxTradeMs int64, hasWarnings bool) { +// updateAccountMeta persists balance, trade sync timestamp, and bill sync timestamp. +func (e *Engine) updateAccountMeta(accountID uint, balance *platform.Balance, maxTradeMs, maxBillMs int64, hasWarnings bool) { syncAt := time.Now().Unix() if hasWarnings { if maxTradeMs > 0 { @@ -234,6 +280,12 @@ func (e *Engine) updateAccountMeta(accountID uint, balance *platform.Balance, ma } else { _ = e.orm.UpdateAccountBalanceAndSyncTime(accountID, 0, 0, 0, 0, syncAt) } + + // Update bill sync time independently — only advance if we got bill records. + if maxBillMs > 0 { + billSyncAt := maxBillMs / 1000 + _ = e.orm.UpdateAccountBillSyncTime(accountID, billSyncAt) + } } func toTradeModel(r platform.TradeRecord, accountID uint, source, tradeType string) model.TradeRecord { @@ -321,6 +373,16 @@ func maxTradeAt(buys, sells []platform.TradeRecord) int64 { return max } +func maxBillAt(bills []platform.BillRecord) int64 { + var max int64 + for _, b := range bills { + if b.AddTime > max { + max = b.AddTime + } + } + return max +} + var Module = fx.Module("sync", logfx.WithComponent("sync"), fx.Provide(NewEngine),