From 1a88e4399d9d6e5f31e8b9d6a5265765f2a5d90e Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 17 May 2026 23:44:23 +0800 Subject: [PATCH 01/16] feat: add BillRecord model and migration --- migrations/003_bill_records.sql | 16 ++++++++++++++++ pkg/model/bill.go | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 migrations/003_bill_records.sql create mode 100644 pkg/model/bill.go diff --git a/migrations/003_bill_records.sql b/migrations/003_bill_records.sql new file mode 100644 index 0000000..52abe3e --- /dev/null +++ b/migrations/003_bill_records.sql @@ -0,0 +1,16 @@ +CREATE TABLE 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 idx_bill_account ON bill_records(account_id, add_time DESC); +CREATE INDEX idx_bill_order ON bill_records(order_no); diff --git a/pkg/model/bill.go b/pkg/model/bill.go new file mode 100644 index 0000000..1440861 --- /dev/null +++ b/pkg/model/bill.go @@ -0,0 +1,27 @@ +package model + +import "gorm.io/gorm" + +const ( + BillTypePurchase = 1 + BillTypeSell = 2 + BillTypeRentalIncome = 3 + BillTypeRentalFee = 4 + BillTypeRecharge = 5 + BillTypeWithdraw = 6 + BillTypeRefund = 7 + BillTypeOther = 99 +) + +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" } From eb46fe973accc9a164435ba350d07728fad58e2d Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 17 May 2026 23:48:00 +0800 Subject: [PATCH 02/16] feat: add BillRecord ORM interface and implementation --- pkg/orm/bill.go | 34 ++++++++++++++++++++++++++++++++++ pkg/orm/interfaces.go | 9 +++++++++ pkg/orm/orm.go | 1 + 3 files changed, 44 insertions(+) create mode 100644 pkg/orm/bill.go diff --git a/pkg/orm/bill.go b/pkg/orm/bill.go new file mode 100644 index 0000000..36119ce --- /dev/null +++ b/pkg/orm/bill.go @@ -0,0 +1,34 @@ +package orm + +import "github.com/CsJsss/CS2Ledger/pkg/model" + +func (o *ormImpl) CreateBill(r *model.BillRecord) error { + return o.db.Create(r).Error +} + +func (o *ormImpl) ListBillsByAccount(accountID uint, limit, offset int) ([]model.BillRecord, error) { + var records []model.BillRecord + err := o.db.Where("account_id = ?", accountID). + Order("add_time DESC").Limit(limit).Offset(offset). + Find(&records).Error + return records, err +} + +func (o *ormImpl) ListAllBills(limit, offset int) ([]model.BillRecord, error) { + var records []model.BillRecord + err := o.db.Order("add_time DESC").Limit(limit).Offset(offset). + Find(&records).Error + return records, err +} + +func (o *ormImpl) CountBillsByAccount(accountID uint) (int64, error) { + var count int64 + err := o.db.Model(&model.BillRecord{}).Where("account_id = ?", accountID).Count(&count).Error + return count, err +} + +func (o *ormImpl) CountAllBills() (int64, error) { + var count int64 + err := o.db.Model(&model.BillRecord{}).Count(&count).Error + return count, err +} diff --git a/pkg/orm/interfaces.go b/pkg/orm/interfaces.go index d2203b4..ab7f39b 100644 --- a/pkg/orm/interfaces.go +++ b/pkg/orm/interfaces.go @@ -59,10 +59,19 @@ type RentalInterface interface { FindRentalsByAccount(accountID uint) ([]model.RentalRecord, error) } +type BillInterface interface { + CreateBill(*model.BillRecord) error + ListBillsByAccount(accountID uint, limit, offset int) ([]model.BillRecord, error) + ListAllBills(limit, offset int) ([]model.BillRecord, error) + CountBillsByAccount(accountID uint) (int64, error) + CountAllBills() (int64, 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) From cc81e63a77d68b894b753f2f71d82ffd085df176 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 17 May 2026 23:50:54 +0800 Subject: [PATCH 03/16] feat: add GetBillHistory to platform Client interface --- pkg/platform/buff/client.go | 4 ++++ pkg/platform/c5/client.go | 4 ++++ pkg/platform/client.go | 12 ++++++++++++ pkg/platform/eco/client.go | 4 ++++ pkg/platform/fake_client.go | 5 +++++ pkg/platform/igxe/client.go | 4 ++++ pkg/platform/youpin/client.go | 4 ++++ 7 files changed, 37 insertions(+) diff --git a/pkg/platform/buff/client.go b/pkg/platform/buff/client.go index c55d977..74d0b5a 100644 --- a/pkg/platform/buff/client.go +++ b/pkg/platform/buff/client.go @@ -210,6 +210,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/c5/client.go b/pkg/platform/c5/client.go index 2da4c53..a329987 100644 --- a/pkg/platform/c5/client.go +++ b/pkg/platform/c5/client.go @@ -70,6 +70,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) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { cfg := platform.ApplyQueryOpts(opts) c.Log.Info("c5: fetching buy history", "since", cfg.Since) diff --git a/pkg/platform/client.go b/pkg/platform/client.go index c4b70b3..5234eec 100644 --- a/pkg/platform/client.go +++ b/pkg/platform/client.go @@ -48,6 +48,17 @@ 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 @@ -95,4 +106,5 @@ type Client interface { 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/eco/client.go b/pkg/platform/eco/client.go index 35975d7..5a17017 100644 --- a/pkg/platform/eco/client.go +++ b/pkg/platform/eco/client.go @@ -86,6 +86,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) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { cfg := platform.ApplyQueryOpts(opts) c.Log.Info("eco: fetching buy history", "since", cfg.Since) diff --git a/pkg/platform/fake_client.go b/pkg/platform/fake_client.go index 2181c86..cb8f395 100644 --- a/pkg/platform/fake_client.go +++ b/pkg/platform/fake_client.go @@ -11,6 +11,7 @@ type FakeClient struct { BuyHistory []TradeRecord SellHistory []TradeRecord BalanceResp *Balance + BillHistory []BillRecord } func (f *FakeClient) Verify(_ context.Context) error { @@ -34,3 +35,7 @@ func (f *FakeClient) GetBalance(_ context.Context) (*Balance, error) { } return &Balance{}, nil } + +func (f *FakeClient) GetBillHistory(_ context.Context, _ ...QueryOption) ([]BillRecord, error) { + return f.BillHistory, nil +} diff --git a/pkg/platform/igxe/client.go b/pkg/platform/igxe/client.go index 2f8608e..ae6d5d5 100644 --- a/pkg/platform/igxe/client.go +++ b/pkg/platform/igxe/client.go @@ -88,6 +88,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) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.TradeRecord, error) { return []platform.TradeRecord{}, nil } diff --git a/pkg/platform/youpin/client.go b/pkg/platform/youpin/client.go index 0f08625..7429cfd 100644 --- a/pkg/platform/youpin/client.go +++ b/pkg/platform/youpin/client.go @@ -420,6 +420,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 +} + // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- From 9d99353be60b9dbf46c89793845d646d16634665 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 17 May 2026 23:54:28 +0800 Subject: [PATCH 04/16] feat(youpin): implement GetBillHistory --- pkg/platform/youpin/client.go | 72 +++++++++++++++++++++++++++++++++- pkg/platform/youpin/convert.go | 29 ++++++++++++++ pkg/platform/youpin/model.go | 19 +++++++++ 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/pkg/platform/youpin/client.go b/pkg/platform/youpin/client.go index 7429cfd..5990ff7 100644 --- a/pkg/platform/youpin/client.go +++ b/pkg/platform/youpin/client.go @@ -420,8 +420,76 @@ 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) 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) + + var all []platform.BillRecord + page := 1 + for { + if err := ctx.Err(); err != nil { + return all, err + } + items, hasMore, err := c.fetchBillPage(ctx, page, cfg.Since) + if err != nil { + return all, fmt.Errorf("youpin bill page %d: %w", page, err) + } + all = append(all, items...) + if cfg.Limit > 0 && len(all) >= cfg.Limit { + return all[:cfg.Limit], nil + } + if !hasMore { + break + } + page++ + select { + case <-time.After(1 * time.Second): + case <-ctx.Done(): + return all, ctx.Err() + } + } + c.Log.Info("youpin: bill history done", "total", len(all)) + return all, nil +} + +func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]platform.BillRecord, bool, error) { + body := map[string]any{ + "pageIndex": page, + "pageSize": DefaultPageSize, + "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 + } + + var result youpinBillPageResponse + if err := json.Unmarshal(respBody, &result); err != nil { + c.Log.Warn("youpin bill page failed", "page", page, "err", err) + return nil, false, err + } + if result.Code != 0 { + c.Log.Warn("youpin bill page: API error", "code", result.Code) + return nil, false, fmt.Errorf("youpin bill API error: code=%d", result.Code) + } + + records := make([]platform.BillRecord, 0, len(result.Data.DataList)) + finished := false + for _, item := range result.Data.DataList { + rec := toBillRecord(item) + if rec.AddTime < since { + finished = true + continue + } + records = append(records, rec) + } + + hasMore := !finished && len(result.Data.DataList) == DefaultPageSize + if result.Data.Total > 0 { + hasMore = page*DefaultPageSize < result.Data.Total + } + return records, hasMore, nil } // --------------------------------------------------------------------------- diff --git a/pkg/platform/youpin/convert.go b/pkg/platform/youpin/convert.go index e871f2a..ac3a1d3 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,31 @@ 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 3: + return model.BillTypePurchase + case 16: + return model.BillTypeRentalIncome + case 187: + return model.BillTypeRentalFee + default: + return model.BillTypeOther + } +} + +func toBillRecord(item youpinBillItem) platform.BillRecord { + money := parseYoupinPrice(json.Number(item.ThisMoney)) + t, _ := time.ParseInLocation("2006-01-02 15:04:05", item.AddTime, time.Local) + addTimeMs := t.UnixMilli() + + return platform.BillRecord{ + TypeName: item.TypeName, + TypeID: youpinTypeToInternal(item.TypeID), + ThisMoney: money, + OrderNo: item.OrderNo, + AddTime: addTimeMs, + } +} diff --git a/pkg/platform/youpin/model.go b/pkg/platform/youpin/model.go index b1377b3..bf64a23 100644 --- a/pkg/platform/youpin/model.go +++ b/pkg/platform/youpin/model.go @@ -119,3 +119,22 @@ 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 youpinBillPageResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Total int `json:"total"` + DataList []youpinBillItem `json:"dataList"` + } `json:"data"` +} From d4c728442f09c09fc71f0033e7e2e8be0b6e7e55 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 17 May 2026 23:57:48 +0800 Subject: [PATCH 05/16] fix(youpin): handle bill time parse errors instead of silently truncating --- pkg/platform/youpin/client.go | 6 +++++- pkg/platform/youpin/convert.go | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/platform/youpin/client.go b/pkg/platform/youpin/client.go index 5990ff7..781c308 100644 --- a/pkg/platform/youpin/client.go +++ b/pkg/platform/youpin/client.go @@ -477,7 +477,11 @@ func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]pl records := make([]platform.BillRecord, 0, len(result.Data.DataList)) finished := false for _, item := range result.Data.DataList { - rec := toBillRecord(item) + 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 diff --git a/pkg/platform/youpin/convert.go b/pkg/platform/youpin/convert.go index ac3a1d3..475e1dd 100644 --- a/pkg/platform/youpin/convert.go +++ b/pkg/platform/youpin/convert.go @@ -112,9 +112,12 @@ func youpinTypeToInternal(typeID int) int { } } -func toBillRecord(item youpinBillItem) platform.BillRecord { +func toBillRecord(item youpinBillItem) (platform.BillRecord, error) { money := parseYoupinPrice(json.Number(item.ThisMoney)) - t, _ := time.ParseInLocation("2006-01-02 15:04:05", item.AddTime, time.Local) + 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() return platform.BillRecord{ @@ -123,5 +126,5 @@ func toBillRecord(item youpinBillItem) platform.BillRecord { ThisMoney: money, OrderNo: item.OrderNo, AddTime: addTimeMs, - } + }, nil } From 79e1aa869673697ee75ad3ea9140105bd59fef50 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:00:36 +0800 Subject: [PATCH 06/16] feat(sync): fetch and persist bill records during sync --- pkg/service/sync/engine.go | 42 +++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/pkg/service/sync/engine.go b/pkg/service/sync/engine.go index ec07e57..99d276e 100644 --- a/pkg/service/sync/engine.go +++ b/pkg/service/sync/engine.go @@ -65,7 +65,7 @@ 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) result := &SyncResult{Warnings: warnings} maxTradeMs := maxTradeAt(buys, sells) @@ -77,6 +77,25 @@ 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() @@ -123,9 +142,9 @@ func (e *Engine) createAndVerify(acc *model.Account) (platform.Client, error) { return client, nil } -// fetchData concurrently pulls buy history, sell history, and balance. +// fetchData concurrently pulls buy history, sell history, balance, and bill history. func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( - []platform.TradeRecord, []platform.TradeRecord, *platform.Balance, []string, + []platform.TradeRecord, []platform.TradeRecord, *platform.Balance, []platform.BillRecord, []string, ) { since := int64(0) if lastSyncAt != nil { @@ -136,13 +155,14 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( 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() @@ -180,8 +200,20 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( mu.Unlock() }() + go func() { + defer wg.Done() + b, err := client.GetBillHistory(ctx, platform.WithSince(since)) + 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. From 7409f50637de583c2003f83e36794621ff899303 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:04:17 +0800 Subject: [PATCH 07/16] feat: expose GetBillRecords via Wails binding --- app.go | 4 ++++ main.go | 2 ++ pkg/service/bill/service.go | 37 +++++++++++++++++++++++++++++++++++++ pkg/service/service.go | 5 +++++ 4 files changed, 48 insertions(+) create mode 100644 pkg/service/bill/service.go diff --git a/app.go b/app.go index f122181..98089c7 100644 --- a/app.go +++ b/app.go @@ -197,6 +197,10 @@ func (a *App) GetRentalHistory(accountID uint, assetID string) ([]model.RentalRe return a.svc.Rental().ListByAsset(accountID, assetID) } +func (a *App) GetBillRecords(accountID uint) ([]model.BillRecord, error) { + return a.svc.Bill().List(accountID) +} + type UserSettings struct { PriceSource string `json:"priceSource"` PriceCacheTTL int `json:"priceCacheTtl"` 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/pkg/service/bill/service.go b/pkg/service/bill/service.go new file mode 100644 index 0000000..1b36bf6 --- /dev/null +++ b/pkg/service/bill/service.go @@ -0,0 +1,37 @@ +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 BillInterface interface { + List(accountID uint) ([]model.BillRecord, error) +} + +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) List(accountID uint) ([]model.BillRecord, error) { + if accountID == 0 { + return s.orm.ListAllBills(1000, 0) + } + return s.orm.ListBillsByAccount(accountID, 1000, 0) +} + +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 } From 14d82f38616e4e485ba88180dbe4a841350d4e0d Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:08:10 +0800 Subject: [PATCH 08/16] feat: add useBillRecords hook --- frontend/src/hooks/useBillRecords.ts | 12 ++++++++++++ frontend/src/lib/wails.ts | 2 ++ 2 files changed, 14 insertions(+) create mode 100644 frontend/src/hooks/useBillRecords.ts diff --git a/frontend/src/hooks/useBillRecords.ts b/frontend/src/hooks/useBillRecords.ts new file mode 100644 index 0000000..1f273a3 --- /dev/null +++ b/frontend/src/hooks/useBillRecords.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { GetBillRecords } from "../lib/wails"; +import type { model } from "../lib/wails"; + +export function useBillRecords(accountId: number | null) { + return useQuery({ + queryKey: ["billRecords", accountId ?? "all"], + queryFn: () => GetBillRecords(accountId ?? 0), + staleTime: 2 * 60 * 1000, + enabled: accountId !== undefined, + }); +} diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index 49773a0..5cba805 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -14,6 +14,7 @@ import { GetMonthlyBreakdown, GetDashboardSummary, GetRentalHistory, + GetBillRecords, GetMarketPrices, GetSettings, UpdateSettings, @@ -36,6 +37,7 @@ export { GetMonthlyBreakdown, GetDashboardSummary, GetRentalHistory, + GetBillRecords, GetMarketPrices, GetSettings, UpdateSettings, From 202cd3a8498a2d9fc0161c3dff88a5b2532cc322 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:10:48 +0800 Subject: [PATCH 09/16] feat: add BillPage component --- frontend/src/pages/BillPage.tsx | 150 ++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 frontend/src/pages/BillPage.tsx diff --git a/frontend/src/pages/BillPage.tsx b/frontend/src/pages/BillPage.tsx new file mode 100644 index 0000000..45debef --- /dev/null +++ b/frontend/src/pages/BillPage.tsx @@ -0,0 +1,150 @@ +import { useState } 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 Chip from "@mui/material/Chip"; +import SortableTable from "../components/SortableTable"; +import PageSearchBar from "../components/PageSearchBar"; +import ErrorBanner from "../components/ErrorBanner"; +import { useBillRecords } from "../hooks/useBillRecords"; +import { useUIStore } from "../store/uiStore"; +import { platformLabel } from "../lib/constants"; +import { formatCNY } from "../lib/format"; +import type { model } from "../lib/wails"; + +const TYPE_COLORS: Record = { + 1: "error", // 购买 + 2: "success", // 出售 + 3: "success", // 收取租金 + 4: "error", // 租赁服务费 + 5: "info", // 充值 + 6: "warning", // 提现 + 7: "info", // 退款 + 99: "default", // 其他 +}; + +export default function BillPage() { + const selectedAccountId = useUIStore((s) => s.selectedAccountId); + const [searchQuery, setSearchQuery] = useState(""); + + const { data: bills = [], isLoading, error, refetch } = useBillRecords(selectedAccountId); + + const columns: ColumnDef[] = [ + { + 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) => ( + + {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} + + ); + }, + }, + ]; + + return ( + + + 资金流水 + + + + {error && ( + + void refetch()} + /> + + )} + + {isLoading && Loading...} + + {!isLoading && !error && bills.length === 0 && ( + + 暂无流水记录。同步账户数据后将自动拉取资金流水。 + + )} + + {bills.length > 0 && ( + + (row.original.orderNo ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) || + (row.original.typeName ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) + } + getRowId={(b) => String(b.ID)} + /> + )} + + ); +} From 2404867598dc08d2cc81fbdd6eb268419ba0109f Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:12:19 +0800 Subject: [PATCH 10/16] feat: add Bill page to routing and navigation --- frontend/src/App.tsx | 2 ++ frontend/src/components/AppLayout.tsx | 1 + 2 files changed, 3 insertions(+) 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: }, ]; From 826f68218f24005693cadc9c7c03d5678c5e1624 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 00:13:27 +0800 Subject: [PATCH 11/16] fix: remove unused model import from useBillRecords hook --- frontend/src/hooks/useBillRecords.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/hooks/useBillRecords.ts b/frontend/src/hooks/useBillRecords.ts index 1f273a3..ba38660 100644 --- a/frontend/src/hooks/useBillRecords.ts +++ b/frontend/src/hooks/useBillRecords.ts @@ -1,6 +1,5 @@ import { useQuery } from "@tanstack/react-query"; import { GetBillRecords } from "../lib/wails"; -import type { model } from "../lib/wails"; export function useBillRecords(accountId: number | null) { return useQuery({ From 8c6b3ca46859e10ede42f02b79c88f7db4e59d60 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Mon, 18 May 2026 23:21:34 +0800 Subject: [PATCH 12/16] feat(bill): add bill pages --- frontend/src/pages/BillPage.tsx | 515 ++++++++++++++++++++++++------ frontend/tsconfig.tsbuildinfo | 2 +- frontend/wailsjs/go/main/App.d.ts | 13 +- frontend/wailsjs/go/main/App.js | 24 +- frontend/wailsjs/go/models.ts | 53 +++ pkg/model/bill.go | 27 +- pkg/orm/bill.go | 10 +- 7 files changed, 507 insertions(+), 137 deletions(-) diff --git a/frontend/src/pages/BillPage.tsx b/frontend/src/pages/BillPage.tsx index 45debef..edb3d0e 100644 --- a/frontend/src/pages/BillPage.tsx +++ b/frontend/src/pages/BillPage.tsx @@ -1,112 +1,319 @@ -import { useState } from "react"; +import { useState, useMemo } 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 SortableTable from "../components/SortableTable"; import PageSearchBar from "../components/PageSearchBar"; import ErrorBanner from "../components/ErrorBanner"; +import EmptyState from "../components/EmptyState"; import { useBillRecords } from "../hooks/useBillRecords"; +import { useAccounts } from "../hooks/useAccounts"; import { useUIStore } from "../store/uiStore"; -import { platformLabel } from "../lib/constants"; +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: "error", // 租赁服务费 - 5: "info", // 充值 - 6: "warning", // 提现 - 7: "info", // 退款 - 99: "default", // 其他 + 1: "error", + 2: "success", + 3: "success", + 4: "error", + 5: "info", + 6: "warning", + 7: "info", + 8: "info", + 99: "default", +}; + +const TYPE_LABELS: Record = { + 1: "购买", + 2: "出售", + 3: "收取租金", + 4: "租赁服务费", + 5: "充值", + 6: "提现", + 7: "退款", + 8: "求购账户充值", + 99: "其他", +}; + +const CHART_COLORS: Record = { + 1: "#d32f2f", + 2: "#2e7d32", + 3: "#1565c0", + 4: "#e65100", + 5: "#6a1b9a", + 6: "#f9a825", + 7: "#00838f", + 8: "#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 { data: bills = [], isLoading, error, refetch } = useBillRecords(selectedAccountId); + const { data: accounts = [] } = useAccounts(); - const columns: ColumnDef[] = [ - { - accessorKey: "addTime", - header: "时间", - cell: (info) => { - const v = info.getValue() as number; - return ( - - {new Date(v).toLocaleString("zh-CN")} - - ); + 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(bills.map((b) => b.typeId)); + return Array.from(ids).sort((a, b) => a - b); + }, [bills]); + + const filteredBills = useMemo(() => { + let result = bills; + + if (platformFilter) { + result = result.filter((b) => b.platform === platformFilter); + } + if (typeIdFilter !== "") { + result = result.filter((b) => b.typeId === typeIdFilter); + } + if (startDateStr) { + const startMs = new Date(startDateStr + "T00:00:00+08:00").getTime(); + result = result.filter((b) => b.addTime >= startMs); + } + if (endDateStr) { + const endMs = new Date(endDateStr + "T23:59:59.999+08:00").getTime(); + result = result.filter((b) => b.addTime <= endMs); + } + + return result; + }, [bills, platformFilter, typeIdFilter, startDateStr, endDateStr]); + + const typeTotals = useMemo(() => { + const totals: Record = {}; + for (const bill of filteredBills) { + totals[bill.typeId] = (totals[bill.typeId] ?? 0) + bill.thisMoney; + } + return totals; + }, [filteredBills]); + + const chartOption = useMemo(() => { + if (filteredBills.length === 0) return null; + + const sorted = [...filteredBills].sort((a, b) => a.addTime - b.addTime); + const typeIds = [...new Set(sorted.map((b) => b.typeId))].sort((a, b) => a - b); + + const dayBuckets: Record> = {}; + for (const bill of sorted) { + const d = new Date(bill.addTime); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + if (!dayBuckets[dateKey]) dayBuckets[dateKey] = {}; + dayBuckets[dateKey][bill.typeId] = (dayBuckets[dateKey][bill.typeId] ?? 0) + bill.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)}`; + }, + }, }, - }, - { - accessorKey: "platform", - header: "平台", - cell: (info) => ( - - {platformLabel[info.getValue() as string] ?? (info.getValue() as string)} - - ), - }, - { - id: "account", - header: "账户", - cell: (info) => ( - - {info.row.original.accountId ?? "—"} - - ), - }, - { - accessorKey: "typeName", - header: "类型", - cell: (info) => { - const typeId = info.row.original.typeId; - return ( - - ); + 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" }, + ], + }; + }, [filteredBills]); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "addTime", + header: "时间", + cell: (info) => { + const v = info.getValue() as number; + return ( + + {new Date(v).toLocaleString("zh-CN")} + + ); + }, }, - }, - { - accessorKey: "thisMoney", - header: "金额", - meta: { align: "right" }, - cell: (info) => { - const v = info.getValue() as number; - return ( - = 0 ? "success.main" : "error.main"} - > - {formatCNY(v)} + { + accessorKey: "platform", + header: "平台", + cell: (info) => ( + + {platformLabel[info.getValue() as string] ?? (info.getValue() as string)} - ); + ), }, - }, - { - accessorKey: "orderNo", - header: "订单号", - cell: (info) => { - const v = info.getValue() as string; - if (!v) return ; - return ( - - {v.length > 24 ? v.slice(0, 24) + "..." : v} + { + 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 ( @@ -117,33 +324,139 @@ export default function BillPage() { {error && ( - void refetch()} - /> + void refetch()} /> )} - {isLoading && Loading...} + {isLoading && ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + + + )} {!isLoading && !error && bills.length === 0 && ( - - 暂无流水记录。同步账户数据后将自动拉取资金流水。 - + 暂无流水记录。同步账户数据后将自动拉取资金流水。 )} - {bills.length > 0 && ( - - (row.original.orderNo ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) || - (row.original.typeName ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) - } - getRowId={(b) => String(b.ID)} - /> + {!isLoading && !error && bills.length > 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 */} + {filteredBills.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 */} + + + 累计资金流水趋势 + {filteredBills.length === 0 ? ( + + 所选筛选条件下没有数据 + + ) : chartOption ? ( + + + + ) : null} + + + + {/* Table */} + {filteredBills.length > 0 && ( + + (row.original.orderNo ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) || + (row.original.typeName ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) + } + getRowId={(b) => String(b.ID)} + /> + )} + + {filteredBills.length === 0 && ( + + )} + )} ); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 2e31ea7..84aee43 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/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..14d7079 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -4,7 +4,6 @@ import {model} from '../models'; import {trade} from '../models'; import {main} from '../models'; import {inventory} from '../models'; -import {platform} from '../models'; import {pnl} from '../models'; import {sync} from '../models'; @@ -14,26 +13,24 @@ export function DeleteAccount(arg1:number):Promise; export function GetAccounts():Promise>; -export function GetCompletedTrades(arg1:number,arg2:number,arg3:number,arg4:string,arg5:string):Promise; +export function GetBillRecords(arg1:number):Promise>; + +export function GetCompletedTrades(arg1:number):Promise>; export function GetCompletedTradesSummary(arg1:number):Promise; export function GetDashboardSummary():Promise; -export function GetInventory(arg1:number,arg2:string,arg3:string,arg4:number,arg5:number,arg6:string,arg7:string):Promise; +export function GetInventory(arg1:number,arg2:string):Promise>; export function GetItemDetail(arg1:number,arg2:string):Promise; -export function GetMarketPrices():Promise>; - export function GetMonthlyBreakdown(arg1:number,arg2:number):Promise>; export function GetPnlSummary(arg1:number):Promise; export function GetRentalHistory(arg1:number,arg2:string):Promise>; -export function GetSettings():Promise; - export function GetUnmatchedSells(arg1:number):Promise>; export function SyncAccount(arg1:number):Promise; @@ -41,5 +38,3 @@ export function SyncAccount(arg1:number):Promise; export function UpdateAccount(arg1:model.Account):Promise; export function UpdateAccountInfo(arg1:number,arg2:string,arg3:string):Promise; - -export function UpdateSettings(arg1:main.UserSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 927cb8c..961517f 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,8 +14,12 @@ export function GetAccounts() { return window['go']['main']['App']['GetAccounts'](); } -export function GetCompletedTrades(arg1, arg2, arg3, arg4, arg5) { - return window['go']['main']['App']['GetCompletedTrades'](arg1, arg2, arg3, arg4, arg5); +export function GetBillRecords(arg1) { + return window['go']['main']['App']['GetBillRecords'](arg1); +} + +export function GetCompletedTrades(arg1) { + return window['go']['main']['App']['GetCompletedTrades'](arg1); } export function GetCompletedTradesSummary(arg1) { @@ -26,18 +30,14 @@ export function GetDashboardSummary() { return window['go']['main']['App']['GetDashboardSummary'](); } -export function GetInventory(arg1, arg2, arg3, arg4, arg5, arg6, arg7) { - return window['go']['main']['App']['GetInventory'](arg1, arg2, arg3, arg4, arg5, arg6, arg7); +export function GetInventory(arg1, arg2) { + return window['go']['main']['App']['GetInventory'](arg1, arg2); } export function GetItemDetail(arg1, arg2) { return window['go']['main']['App']['GetItemDetail'](arg1, arg2); } -export function GetMarketPrices() { - return window['go']['main']['App']['GetMarketPrices'](); -} - export function GetMonthlyBreakdown(arg1, arg2) { return window['go']['main']['App']['GetMonthlyBreakdown'](arg1, arg2); } @@ -50,10 +50,6 @@ export function GetRentalHistory(arg1, arg2) { return window['go']['main']['App']['GetRentalHistory'](arg1, arg2); } -export function GetSettings() { - return window['go']['main']['App']['GetSettings'](); -} - export function GetUnmatchedSells(arg1) { return window['go']['main']['App']['GetUnmatchedSells'](arg1); } @@ -69,7 +65,3 @@ export function UpdateAccount(arg1) { export function UpdateAccountInfo(arg1, arg2, arg3) { return window['go']['main']['App']['UpdateAccountInfo'](arg1, arg2, arg3); } - -export function UpdateSettings(arg1) { - return window['go']['main']['App']['UpdateSettings'](arg1); -} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 28a5941..eca0a22 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -253,6 +253,59 @@ export namespace model { 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 { + 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 TradeRecord { ID: number; // Go type: time diff --git a/pkg/model/bill.go b/pkg/model/bill.go index 1440861..5680eb4 100644 --- a/pkg/model/bill.go +++ b/pkg/model/bill.go @@ -2,15 +2,26 @@ 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 the platform's original type label (e.g. "购买饰品", "提现"). +// When TypeID == BillTypeOther, the frontend displays TypeName as-is since there +// is no standardized mapping for that type. const ( - BillTypePurchase = 1 - BillTypeSell = 2 - BillTypeRentalIncome = 3 - BillTypeRentalFee = 4 - BillTypeRecharge = 5 - BillTypeWithdraw = 6 - BillTypeRefund = 7 - BillTypeOther = 99 + BillTypePurchase = 1 // 购买 + BillTypeSell = 2 // 出售 + BillTypeRentalIncome = 3 // 收取租金 + BillTypeRentalFee = 4 // 租赁服务费 + BillTypeRecharge = 5 // 充值 + BillTypeWithdraw = 6 // 提现 + BillTypeRefund = 7 // 退款 + BillTypeRechargForPurchaseAccount = 8 // 求购账户充值 + BillTypeOther = 99 // 其他 — 回退到平台原始 TypeName 展示 ) type BillRecord struct { diff --git a/pkg/orm/bill.go b/pkg/orm/bill.go index 36119ce..11188fc 100644 --- a/pkg/orm/bill.go +++ b/pkg/orm/bill.go @@ -16,7 +16,10 @@ func (o *ormImpl) ListBillsByAccount(accountID uint, limit, offset int) ([]model func (o *ormImpl) ListAllBills(limit, offset int) ([]model.BillRecord, error) { var records []model.BillRecord - err := o.db.Order("add_time DESC").Limit(limit).Offset(offset). + err := o.db. + Joins("JOIN accounts ON accounts.id = bill_records.account_id"). + Where("accounts.deleted_at IS NULL"). + Order("add_time DESC").Limit(limit).Offset(offset). Find(&records).Error return records, err } @@ -29,6 +32,9 @@ func (o *ormImpl) CountBillsByAccount(accountID uint) (int64, error) { func (o *ormImpl) CountAllBills() (int64, error) { var count int64 - err := o.db.Model(&model.BillRecord{}).Count(&count).Error + err := o.db.Model(&model.BillRecord{}). + Joins("JOIN accounts ON accounts.id = bill_records.account_id"). + Where("accounts.deleted_at IS NULL"). + Count(&count).Error return count, err } From 343cf489fb858dabbb2a86c5a7b2ebaacb48e14e Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Thu, 21 May 2026 22:14:58 +0800 Subject: [PATCH 13/16] feat(bill): add platform-cli and add bill types --- cmd/platform-cli/cli/billhistory.go | 70 +++++++++++++++++++++++++++++ cmd/platform-cli/cli/output.go | 8 ++++ cmd/platform-cli/cli/root.go | 1 + frontend/src/pages/BillPage.tsx | 31 +++++++------ pkg/model/bill.go | 35 +++++++++++---- 5 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 cmd/platform-cli/cli/billhistory.go diff --git a/cmd/platform-cli/cli/billhistory.go b/cmd/platform-cli/cli/billhistory.go new file mode 100644 index 0000000..4180dfd --- /dev/null +++ b/cmd/platform-cli/cli/billhistory.go @@ -0,0 +1,70 @@ +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") +} + +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") + + opts := []platform.QueryOption{platform.WithSince(since), platform.WithLimit(limit)} + + 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/pages/BillPage.tsx b/frontend/src/pages/BillPage.tsx index edb3d0e..b12d10e 100644 --- a/frontend/src/pages/BillPage.tsx +++ b/frontend/src/pages/BillPage.tsx @@ -49,11 +49,12 @@ const TYPE_COLORS: Record = { 1: "购买", 2: "出售", 3: "收取租金", - 4: "租赁服务费", - 5: "充值", - 6: "提现", - 7: "退款", - 8: "求购账户充值", + 4: "收取续租资金", + 5: "租赁服务费", + 6: "充值", + 7: "提现", + 8: "退款", + 9: "求购账户充值", 99: "其他", }; @@ -73,11 +75,12 @@ const CHART_COLORS: Record = { 1: "#d32f2f", 2: "#2e7d32", 3: "#1565c0", - 4: "#e65100", - 5: "#6a1b9a", - 6: "#f9a825", - 7: "#00838f", - 8: "#4e342e", + 4: "#00897b", + 5: "#e65100", + 6: "#6a1b9a", + 7: "#f9a825", + 8: "#00838f", + 9: "#4e342e", 99: "#78909c", }; diff --git a/pkg/model/bill.go b/pkg/model/bill.go index 5680eb4..e89b0dd 100644 --- a/pkg/model/bill.go +++ b/pkg/model/bill.go @@ -9,21 +9,40 @@ import "gorm.io/gorm" // TypeID vs TypeName: // - TypeID is our internal constant (BillTypePurchase, etc.). Frontend uses it for // color coding and filtering across platforms. -// - TypeName is the platform's original type label (e.g. "购买饰品", "提现"). -// When TypeID == BillTypeOther, the frontend displays TypeName as-is since there -// is no standardized mapping for that type. +// - 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 // 收取租金 - BillTypeRentalFee = 4 // 租赁服务费 - BillTypeRecharge = 5 // 充值 - BillTypeWithdraw = 6 // 提现 - BillTypeRefund = 7 // 退款 - BillTypeRechargForPurchaseAccount = 8 // 求购账户充值 + BillTypeRenewalRental = 4 // 收取续租资金 + BillTypeRentalFee = 5 // 租赁服务费 + BillTypeRecharge = 6 // 充值 + BillTypeWithdraw = 7 // 提现 + BillTypeRefund = 8 // 退款 + BillTypeRechargForPurchaseAccount = 9 // 求购账户充值 BillTypeOther = 99 // 其他 — 回退到平台原始 TypeName 展示 ) +var billTypeNames = map[int]string{ + BillTypePurchase: "购买", + BillTypeSell: "出售", + BillTypeRentalIncome: "收取租金", + BillTypeRenewalRental: "收取续租资金", + BillTypeRentalFee: "租赁服务费", + BillTypeRecharge: "充值", + BillTypeWithdraw: "提现", + BillTypeRefund: "退款", + BillTypeRechargForPurchaseAccount: "求购账户充值", +} + +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"` From 7d05bc45d9000dcb492d70c334a9b5b74d6ae190 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Thu, 21 May 2026 22:16:18 +0800 Subject: [PATCH 14/16] refactor: reduce redundant code --- pkg/platform/buff/client.go | 87 ++++++------- pkg/platform/buff/model.go | 45 +++---- pkg/platform/c5/client.go | 76 ++++++------ pkg/platform/c5/model.go | 41 ++----- pkg/platform/eco/client.go | 217 ++++++++++++++++----------------- pkg/platform/eco/convert.go | 55 +++++++++ pkg/platform/eco/model.go | 47 +++---- pkg/platform/igxe/client.go | 44 ++++--- pkg/platform/igxe/model.go | 16 ++- pkg/platform/utils.go | 77 +++++++++++- pkg/platform/youpin/client.go | 106 +++++++--------- pkg/platform/youpin/convert.go | 23 +++- pkg/platform/youpin/model.go | 39 +++--- 13 files changed, 476 insertions(+), 397 deletions(-) diff --git a/pkg/platform/buff/client.go b/pkg/platform/buff/client.go index 74d0b5a..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, 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 a329987..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,18 +65,15 @@ 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 } @@ -99,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 @@ -123,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 } @@ -161,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) { @@ -203,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 @@ -228,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/eco/client.go b/pkg/platform/eco/client.go index 5a17017..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,28 +82,86 @@ 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) GetBillHistory(_ context.Context, _ ...platform.QueryOption) ([]platform.BillRecord, error) { - return nil, nil +func (c *Client) GetBillHistory(ctx context.Context, opts ...platform.QueryOption) ([]platform.BillRecord, error) { + cfg := platform.ApplyQueryOpts(opts) + 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 bills, err + } + c.Log.Info("eco: bill history done", "total", len(bills)) + return bills, nil +} + +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 nil, false, err + } + + c.Log.Debug("eco: fund flow raw response", "body", string(respBody)) + + pages, err := parseECO[fundFlowPagesModel](respBody) + if err != nil { + return nil, false, err + } + + c.Log.Debug("eco: fund flow response parsed", "page", page, "totalRecord", pages.TotalRecord, "pageResultLen", len(pages.PageResult), "pageSize", pages.PageSize) + + records := make([]platform.BillRecord, 0, len(pages.PageResult)) + finished := false + for _, item := range pages.PageResult { + rec, err := toBillRecord(item) + if err != nil { + c.Log.Warn("eco: bill item parse failed, skipping", "err", err) + continue + } + if since > 0 && rec.AddTime < since { + finished = true + continue + } + records = append(records, rec) + } + + 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 } 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 := c.fetchHistory(ctx, "buy", cfg, c.fetchBuyPage) + 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 } @@ -104,7 +172,11 @@ func (c *Client) GetBuyHistory(ctx context.Context, opts ...platform.QueryOption 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) + 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 } @@ -112,72 +184,6 @@ func (c *Client) GetSellHistory(ctx context.Context, opts ...platform.QueryOptio 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) - -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) - } - - 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 - } - } - - 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) - }, - ) - if err != nil { - return all, err - } - 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 len(trades) == 0 { - consecutiveEmpty++ - if consecutiveEmpty >= 12 { - break - } - } else { - consecutiveEmpty = 0 - } - windowEnd = windowStart - time.Sleep(500 * time.Millisecond) - } - - return all, 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) { body := map[string]any{ "StartTime": windowStart.Format(dateFormat), @@ -200,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 @@ -223,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 } @@ -253,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 @@ -276,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 } @@ -353,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) { @@ -362,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/igxe/client.go b/pkg/platform/igxe/client.go index ae6d5d5..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,15 +85,12 @@ 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 } @@ -127,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 @@ -148,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 781c308..bbab38f 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,14 @@ 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 + if data.TotalCount > 0 { + return trades, page*DefaultPageSize < data.TotalCount, nil } - return trades, len(result.Data.OrderList) == DefaultPageSize, nil + 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 +342,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 +363,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) { @@ -425,32 +417,16 @@ func (c *Client) GetBillHistory(ctx context.Context, opts ...platform.QueryOptio cfg := platform.ApplyQueryOpts(opts) c.Log.Debug("youpin: fetching bill history", "since", cfg.Since) - var all []platform.BillRecord - page := 1 - for { - if err := ctx.Err(); err != nil { - return all, err - } - items, hasMore, err := c.fetchBillPage(ctx, page, cfg.Since) - if err != nil { - return all, fmt.Errorf("youpin bill page %d: %w", page, err) - } - all = append(all, items...) - if cfg.Limit > 0 && len(all) >= cfg.Limit { - return all[:cfg.Limit], nil - } - if !hasMore { - break - } - page++ - select { - case <-time.After(1 * time.Second): - case <-ctx.Done(): - return all, ctx.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) + }, + ) + if err != nil { + return bills, err } - c.Log.Info("youpin: bill history done", "total", len(all)) - return all, nil + c.Log.Info("youpin: bill history done", "total", len(bills)) + return bills, nil } func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]platform.BillRecord, bool, error) { @@ -464,19 +440,15 @@ func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]pl return nil, false, err } - var result youpinBillPageResponse - if err := json.Unmarshal(respBody, &result); err != nil { + data, err := parseYoupin[youpinBillPageData](respBody) + if err != nil { c.Log.Warn("youpin bill page failed", "page", page, "err", err) return nil, false, err } - if result.Code != 0 { - c.Log.Warn("youpin bill page: API error", "code", result.Code) - return nil, false, fmt.Errorf("youpin bill API error: code=%d", result.Code) - } - records := make([]platform.BillRecord, 0, len(result.Data.DataList)) + records := make([]platform.BillRecord, 0, len(data.DataList)) finished := false - for _, item := range result.Data.DataList { + for _, item := range data.DataList { rec, err := toBillRecord(item) if err != nil { c.Log.Warn("youpin: bill item parse failed, skipping", "err", err) @@ -489,9 +461,9 @@ func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]pl records = append(records, rec) } - hasMore := !finished && len(result.Data.DataList) == DefaultPageSize - if result.Data.Total > 0 { - hasMore = page*DefaultPageSize < result.Data.Total + hasMore := !finished && len(data.DataList) == DefaultPageSize + if data.Total > 0 { + hasMore = page*DefaultPageSize < data.Total } return records, hasMore, nil } @@ -552,6 +524,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 475e1dd..3e0ff92 100644 --- a/pkg/platform/youpin/convert.go +++ b/pkg/platform/youpin/convert.go @@ -101,10 +101,23 @@ func toSellTrade(o youpinSellOrder) platform.TradeRecord { // 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 187: return model.BillTypeRentalFee default: @@ -120,9 +133,15 @@ func toBillRecord(item youpinBillItem) (platform.BillRecord, error) { } addTimeMs := t.UnixMilli() + typeID := youpinTypeToInternal(item.TypeID) + typeName := model.BillTypeName(typeID) + if typeName == "" { + typeName = item.TypeName + } + return platform.BillRecord{ - TypeName: item.TypeName, - TypeID: youpinTypeToInternal(item.TypeID), + TypeName: typeName, + TypeID: typeID, ThisMoney: money, OrderNo: item.OrderNo, AddTime: addTimeMs, diff --git a/pkg/platform/youpin/model.go b/pkg/platform/youpin/model.go index bf64a23..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 --- @@ -130,11 +131,7 @@ type youpinBillItem struct { AddTime string `json:"addTime"` // "2026-05-17 11:49:30" } -type youpinBillPageResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - Total int `json:"total"` - DataList []youpinBillItem `json:"dataList"` - } `json:"data"` +type youpinBillPageData struct { + Total int `json:"total"` + DataList []youpinBillItem `json:"dataList"` } From c4d1e3e386260bcc54a1617e06cd086f885c1107 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Thu, 21 May 2026 22:49:18 +0800 Subject: [PATCH 15/16] feat: add mockery and unittests --- .mockery.yaml | 5 + go.mod | 21 +- go.sum | 47 +++- lefthook.yml | 5 + pkg/platform/client.go | 5 + pkg/platform/client_mock_test.go | 382 +++++++++++++++++++++++++++++++ pkg/platform/client_test.go | 238 +++++++++++++++++++ pkg/platform/fake_client.go | 41 ---- 8 files changed, 694 insertions(+), 50 deletions(-) create mode 100644 .mockery.yaml create mode 100644 pkg/platform/client_mock_test.go create mode 100644 pkg/platform/client_test.go delete mode 100644 pkg/platform/fake_client.go 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/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/pkg/platform/client.go b/pkg/platform/client.go index 5234eec..b5a6b95 100644 --- a/pkg/platform/client.go +++ b/pkg/platform/client.go @@ -1,5 +1,7 @@ package platform +//go:generate go tool mockery + import ( "context" @@ -101,6 +103,9 @@ 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) 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/fake_client.go b/pkg/platform/fake_client.go deleted file mode 100644 index cb8f395..0000000 --- a/pkg/platform/fake_client.go +++ /dev/null @@ -1,41 +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 - BillHistory []BillRecord -} - -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 -} - -func (f *FakeClient) GetBillHistory(_ context.Context, _ ...QueryOption) ([]BillRecord, error) { - return f.BillHistory, nil -} From c15b3341b789959288022e74dda5e80032874bf4 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Sun, 7 Jun 2026 13:03:31 +0800 Subject: [PATCH 16/16] feat: add bill history for the uuyp --- app.go | 36 ++- cmd/platform-cli/cli/billhistory.go | 10 + frontend/src/hooks/useBillRecords.ts | 38 ++- frontend/src/lib/wails.ts | 2 + frontend/src/pages/BillPage.tsx | 458 ++++++++++++++++----------- frontend/tsconfig.tsbuildinfo | 2 +- frontend/wailsjs/go/main/App.d.ts | 18 +- frontend/wailsjs/go/main/App.js | 28 +- frontend/wailsjs/go/models.ts | 55 ++++ migrations/003_bill_records.sql | 16 - migrations/004_bill_records.sql | 28 ++ pkg/model/account.go | 1 + pkg/model/bill.go | 2 + pkg/orm/account.go | 5 + pkg/orm/bill.go | 95 +++++- pkg/orm/interfaces.go | 11 +- pkg/platform/client.go | 12 + pkg/platform/youpin/client.go | 34 +- pkg/platform/youpin/convert.go | 2 + pkg/service/bill/service.go | 71 ++++- pkg/service/sync/engine.go | 52 ++- 21 files changed, 716 insertions(+), 260 deletions(-) delete mode 100644 migrations/003_bill_records.sql create mode 100644 migrations/004_bill_records.sql diff --git a/app.go b/app.go index 98089c7..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,8 +201,36 @@ func (a *App) GetRentalHistory(accountID uint, assetID string) ([]model.RentalRe return a.svc.Rental().ListByAsset(accountID, assetID) } -func (a *App) GetBillRecords(accountID uint) ([]model.BillRecord, error) { - return a.svc.Bill().List(accountID) +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 { diff --git a/cmd/platform-cli/cli/billhistory.go b/cmd/platform-cli/cli/billhistory.go index 4180dfd..a7dea0e 100644 --- a/cmd/platform-cli/cli/billhistory.go +++ b/cmd/platform-cli/cli/billhistory.go @@ -23,6 +23,8 @@ 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 { @@ -39,8 +41,16 @@ func runBillHistory(cmd *cobra.Command, args []string) error { 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 { diff --git a/frontend/src/hooks/useBillRecords.ts b/frontend/src/hooks/useBillRecords.ts index ba38660..58a8077 100644 --- a/frontend/src/hooks/useBillRecords.ts +++ b/frontend/src/hooks/useBillRecords.ts @@ -1,11 +1,37 @@ -import { useQuery } from "@tanstack/react-query"; -import { GetBillRecords } from "../lib/wails"; +import { useQuery } from '@tanstack/react-query'; +import { GetBillRecords, GetBillChartData } from '../lib/wails'; -export function useBillRecords(accountId: number | null) { +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: ["billRecords", accountId ?? "all"], - queryFn: () => GetBillRecords(accountId ?? 0), + queryKey: ['billChartData', accountId ?? 0, startTime ?? 0, endTime ?? 0], + queryFn: () => GetBillChartData(accountId ?? 0, startTime ?? 0, endTime ?? 0), staleTime: 2 * 60 * 1000, - enabled: accountId !== undefined, }); } diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index 5cba805..8fcf25f 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -15,6 +15,7 @@ import { GetDashboardSummary, GetRentalHistory, GetBillRecords, + GetBillChartData, GetMarketPrices, GetSettings, UpdateSettings, @@ -38,6 +39,7 @@ export { GetDashboardSummary, GetRentalHistory, GetBillRecords, + GetBillChartData, GetMarketPrices, GetSettings, UpdateSettings, diff --git a/frontend/src/pages/BillPage.tsx b/frontend/src/pages/BillPage.tsx index b12d10e..9f942e5 100644 --- a/frontend/src/pages/BillPage.tsx +++ b/frontend/src/pages/BillPage.tsx @@ -1,36 +1,37 @@ -import { useState, useMemo } 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 SortableTable from "../components/SortableTable"; -import PageSearchBar from "../components/PageSearchBar"; -import ErrorBanner from "../components/ErrorBanner"; -import EmptyState from "../components/EmptyState"; -import { useBillRecords } 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 { 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"; +} from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; echarts.use([ BarChart, @@ -45,54 +46,91 @@ echarts.use([ // 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", - 99: "default", +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: "求购账户充值", - 99: "其他", + 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", + 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 [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: bills = [], isLoading, error, refetch } = useBillRecords(selectedAccountId); const { data: accounts = [] } = useAccounts(); const accountMap = useMemo(() => { @@ -102,51 +140,38 @@ export default function BillPage() { }, [accounts]); const typeFilterOptions = useMemo(() => { - const ids = new Set(bills.map((b) => b.typeId)); + const ids = new Set(chartData.map((b) => b.typeId)); return Array.from(ids).sort((a, b) => a - b); - }, [bills]); + }, [chartData]); + // Table data — already filtered server-side; only search filter applied client-side. const filteredBills = useMemo(() => { - let result = bills; - - if (platformFilter) { - result = result.filter((b) => b.platform === platformFilter); - } - if (typeIdFilter !== "") { - result = result.filter((b) => b.typeId === typeIdFilter); - } - if (startDateStr) { - const startMs = new Date(startDateStr + "T00:00:00+08:00").getTime(); - result = result.filter((b) => b.addTime >= startMs); - } - if (endDateStr) { - const endMs = new Date(endDateStr + "T23:59:59.999+08:00").getTime(); - result = result.filter((b) => b.addTime <= endMs); - } - - return result; - }, [bills, platformFilter, typeIdFilter, startDateStr, endDateStr]); - + 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 bill of filteredBills) { - totals[bill.typeId] = (totals[bill.typeId] ?? 0) + bill.thisMoney; + for (const p of chartData) { + totals[p.typeId] = (totals[p.typeId] ?? 0) + p.thisMoney; } return totals; - }, [filteredBills]); + }, [chartData]); const chartOption = useMemo(() => { - if (filteredBills.length === 0) return null; + if (chartData.length === 0) return null; - const sorted = [...filteredBills].sort((a, b) => a.addTime - b.addTime); - const typeIds = [...new Set(sorted.map((b) => b.typeId))].sort((a, b) => a - b); + const typeIds = [...new Set(chartData.map((p) => p.typeId))].sort((a, b) => a - b); const dayBuckets: Record> = {}; - for (const bill of sorted) { - const d = new Date(bill.addTime); - const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - if (!dayBuckets[dateKey]) dayBuckets[dateKey] = {}; - dayBuckets[dateKey][bill.typeId] = (dayBuckets[dateKey][bill.typeId] ?? 0) + bill.thisMoney; + 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(); @@ -170,54 +195,57 @@ export default function BillPage() { }); const barSeries = { - name: "当日合计", - type: "bar" as const, + name: '当日合计', + type: 'bar' as const, data: dailyTotals, - barWidth: "55%", + barWidth: '55%', itemStyle: { borderRadius: [4, 4, 0, 0], - color: (p: { value: number }) => (p.value >= 0 ? "#2e7d32" : "#c62828"), + 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" }, - }))]; + 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", + trigger: 'axis', + backgroundColor: 'rgba(255,255,255,0.96)', + borderColor: '#e0e0e0', borderWidth: 1, - textStyle: { color: "#333", fontSize: 13 }, + textStyle: { color: '#333', fontSize: 13 }, }, legend: { bottom: 8, - textStyle: { fontSize: 12, color: "#666" }, + textStyle: { fontSize: 12, color: '#666' }, itemWidth: 14, itemHeight: 10, }, grid: { top: 16, left: 64, right: 64, bottom: 48 }, xAxis: { - type: "category", + type: 'category', data: dateKeys, - axisLine: { lineStyle: { color: "#e0e0e0" } }, + axisLine: { lineStyle: { color: '#e0e0e0' } }, axisTick: { show: false }, - axisLabel: { color: "#888", fontSize: 11, rotate: 45 }, + axisLabel: { color: '#888', fontSize: 11, rotate: 45 }, }, yAxis: { - type: "value", - splitLine: { lineStyle: { color: "#f0f0f0" } }, + type: 'value', + splitLine: { lineStyle: { color: '#f0f0f0' } }, axisLabel: { fontSize: 11, - color: "#888", + color: '#888', formatter: (v: number) => { if (Math.abs(v) >= 10000) return `¥${(v / 10000).toFixed(1)}w`; return `¥${v.toFixed(0)}`; @@ -227,37 +255,37 @@ export default function BillPage() { series, dataZoom: [ { - type: "slider", + 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" }, + borderColor: 'transparent', + backgroundColor: '#f5f5f5', + fillerColor: 'rgba(21,101,192,0.1)', + handleStyle: { color: '#1565c0', borderColor: '#1565c0' }, + textStyle: { fontSize: 10, color: '#999' }, }, - { type: "inside" }, + { type: 'inside' }, ], }; - }, [filteredBills]); + }, [chartData]); const columns: ColumnDef[] = useMemo( () => [ { - accessorKey: "addTime", - header: "时间", + accessorKey: 'addTime', + header: '时间', cell: (info) => { const v = info.getValue() as number; return ( - {new Date(v).toLocaleString("zh-CN")} + {new Date(v).toLocaleString('zh-CN')} ); }, }, { - accessorKey: "platform", - header: "平台", + accessorKey: 'platform', + header: '平台', cell: (info) => ( {platformLabel[info.getValue() as string] ?? (info.getValue() as string)} @@ -265,51 +293,61 @@ export default function BillPage() { ), }, { - id: "account", - header: "账户", + id: 'account', + header: '账户', cell: (info) => ( - {accountMap.get(info.row.original.accountId) ?? String(info.row.original.accountId ?? "—")} + {accountMap.get(info.row.original.accountId) ?? + String(info.row.original.accountId ?? '—')} ), }, { - accessorKey: "typeName", - header: "类型", + accessorKey: 'typeName', + header: '类型', cell: (info) => { const typeId = info.row.original.typeId; return ( ); }, }, { - accessorKey: "thisMoney", - header: "金额", - meta: { align: "right" }, + accessorKey: 'thisMoney', + header: '金额', + meta: { align: 'right' }, cell: (info) => { const v = info.getValue() as number; return ( - = 0 ? "success.main" : "error.main"}> + = 0 ? 'success.main' : 'error.main'} + > {formatCNY(v)} ); }, }, { - accessorKey: "orderNo", - header: "订单号", + accessorKey: 'orderNo', + header: '订单号', cell: (info) => { const v = info.getValue() as string; - if (!v) return ; + if (!v) + return ( + + — + + ); return ( - {v.length > 24 ? v.slice(0, 24) + "..." : v} + {v.length > 24 ? v.slice(0, 24) + '...' : v} ); }, @@ -320,7 +358,15 @@ export default function BillPage() { return ( - + 资金流水 @@ -334,9 +380,14 @@ export default function BillPage() { {isLoading && ( - + {[1, 2, 3, 4, 5].map((i) => ( - + ))} @@ -344,14 +395,14 @@ export default function BillPage() { )} - {!isLoading && !error && bills.length === 0 && ( + {!isLoading && !error && totalCount === 0 && ( 暂无流水记录。同步账户数据后将自动拉取资金流水。 )} - {!isLoading && !error && bills.length > 0 && ( + {!isLoading && !error && totalCount > 0 && ( <> {/* Filter bar */} - + @@ -368,14 +421,16 @@ export default function BillPage() { @@ -402,8 +457,8 @@ export default function BillPage() { {/* Summary cards */} - {filteredBills.length > 0 && ( - + {chartData.length > 0 && ( + {Object.keys(typeTotals) .map(Number) .sort((a, b) => a - b) @@ -411,11 +466,15 @@ export default function BillPage() { const total = typeTotals[tid]; return ( - + {TYPE_LABELS[tid] ?? `类型 ${tid}`} - = 0 ? "success.main" : "error.main"}> + = 0 ? 'success.main' : 'error.main'} + > {formatCNY(total)} @@ -428,14 +487,21 @@ export default function BillPage() { {/* Chart */} - 累计资金流水趋势 - {filteredBills.length === 0 ? ( + + 累计资金流水趋势 + + {chartData.length === 0 ? ( 所选筛选条件下没有数据 ) : chartOption ? ( - + ) : null} @@ -443,17 +509,55 @@ export default function BillPage() { {/* Table */} {filteredBills.length > 0 && ( - - (row.original.orderNo ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) || - (row.original.typeName ?? "").toLowerCase().includes((filterValue as string).toLowerCase()) - } - getRowId={(b) => String(b.ID)} - /> + <> + 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 84aee43..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/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/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 +{"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 14d7079..bb2c9de 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1,9 +1,11 @@ // 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'; import {sync} from '../models'; @@ -13,24 +15,30 @@ export function DeleteAccount(arg1:number):Promise; export function GetAccounts():Promise>; -export function GetBillRecords(arg1:number):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):Promise>; +export function GetCompletedTrades(arg1:number,arg2:number,arg3:number,arg4:string,arg5:string):Promise; export function GetCompletedTradesSummary(arg1:number):Promise; export function GetDashboardSummary():Promise; -export function GetInventory(arg1:number,arg2:string):Promise>; +export function GetInventory(arg1:number,arg2:string,arg3:string,arg4:number,arg5:number,arg6:string,arg7:string):Promise; export function GetItemDetail(arg1:number,arg2:string):Promise; +export function GetMarketPrices():Promise>; + export function GetMonthlyBreakdown(arg1:number,arg2:number):Promise>; export function GetPnlSummary(arg1:number):Promise; export function GetRentalHistory(arg1:number,arg2:string):Promise>; +export function GetSettings():Promise; + export function GetUnmatchedSells(arg1:number):Promise>; export function SyncAccount(arg1:number):Promise; @@ -38,3 +46,5 @@ export function SyncAccount(arg1:number):Promise; export function UpdateAccount(arg1:model.Account):Promise; export function UpdateAccountInfo(arg1:number,arg2:string,arg3:string):Promise; + +export function UpdateSettings(arg1:main.UserSettings):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 961517f..7fcf08c 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,12 +14,16 @@ export function GetAccounts() { return window['go']['main']['App']['GetAccounts'](); } -export function GetBillRecords(arg1) { - return window['go']['main']['App']['GetBillRecords'](arg1); +export function GetBillChartData(arg1, arg2, arg3) { + return window['go']['main']['App']['GetBillChartData'](arg1, arg2, arg3); } -export function GetCompletedTrades(arg1) { - return window['go']['main']['App']['GetCompletedTrades'](arg1); +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); } export function GetCompletedTradesSummary(arg1) { @@ -30,14 +34,18 @@ export function GetDashboardSummary() { return window['go']['main']['App']['GetDashboardSummary'](); } -export function GetInventory(arg1, arg2) { - return window['go']['main']['App']['GetInventory'](arg1, arg2); +export function GetInventory(arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + return window['go']['main']['App']['GetInventory'](arg1, arg2, arg3, arg4, arg5, arg6, arg7); } export function GetItemDetail(arg1, arg2) { return window['go']['main']['App']['GetItemDetail'](arg1, arg2); } +export function GetMarketPrices() { + return window['go']['main']['App']['GetMarketPrices'](); +} + export function GetMonthlyBreakdown(arg1, arg2) { return window['go']['main']['App']['GetMonthlyBreakdown'](arg1, arg2); } @@ -50,6 +58,10 @@ export function GetRentalHistory(arg1, arg2) { return window['go']['main']['App']['GetRentalHistory'](arg1, arg2); } +export function GetSettings() { + return window['go']['main']['App']['GetSettings'](); +} + export function GetUnmatchedSells(arg1) { return window['go']['main']['App']['GetUnmatchedSells'](arg1); } @@ -65,3 +77,7 @@ export function UpdateAccount(arg1) { export function UpdateAccountInfo(arg1, arg2, arg3) { return window['go']['main']['App']['UpdateAccountInfo'](arg1, arg2, arg3); } + +export function UpdateSettings(arg1) { + return window['go']['main']['App']['UpdateSettings'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index eca0a22..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,7 @@ 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 { diff --git a/migrations/003_bill_records.sql b/migrations/003_bill_records.sql deleted file mode 100644 index 52abe3e..0000000 --- a/migrations/003_bill_records.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE 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 idx_bill_account ON bill_records(account_id, add_time DESC); -CREATE INDEX idx_bill_order ON bill_records(order_no); 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 index e89b0dd..debc602 100644 --- a/pkg/model/bill.go +++ b/pkg/model/bill.go @@ -21,6 +21,7 @@ const ( BillTypeWithdraw = 7 // 提现 BillTypeRefund = 8 // 退款 BillTypeRechargForPurchaseAccount = 9 // 求购账户充值 + BillTypeWithdrawRefund = 10 // 提现退款 BillTypeOther = 99 // 其他 — 回退到平台原始 TypeName 展示 ) @@ -34,6 +35,7 @@ var billTypeNames = map[int]string{ BillTypeWithdraw: "提现", BillTypeRefund: "退款", BillTypeRechargForPurchaseAccount: "求购账户充值", + BillTypeWithdrawRefund: "提现退款", } func BillTypeName(t int) string { 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 index 11188fc..89ef8e3 100644 --- a/pkg/orm/bill.go +++ b/pkg/orm/bill.go @@ -1,40 +1,103 @@ package orm -import "github.com/CsJsss/CS2Ledger/pkg/model" +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) ([]model.BillRecord, error) { +func (o *ormImpl) ListBillsByAccount(accountID uint, limit, offset int, f BillFilter) ([]model.BillRecord, error) { var records []model.BillRecord - err := o.db.Where("account_id = ?", accountID). - Order("add_time DESC").Limit(limit).Offset(offset). - Find(&records).Error + 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) ([]model.BillRecord, error) { +func (o *ormImpl) ListAllBills(limit, offset int, f BillFilter) ([]model.BillRecord, error) { var records []model.BillRecord - err := o.db. + q := o.db. Joins("JOIN accounts ON accounts.id = bill_records.account_id"). - Where("accounts.deleted_at IS NULL"). - Order("add_time DESC").Limit(limit).Offset(offset). - Find(&records).Error + 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) (int64, error) { +func (o *ormImpl) CountBillsByAccount(accountID uint, f BillFilter) (int64, error) { var count int64 - err := o.db.Model(&model.BillRecord{}).Where("account_id = ?", accountID).Count(&count).Error + 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() (int64, error) { +func (o *ormImpl) CountAllBills(f BillFilter) (int64, error) { var count int64 - err := o.db.Model(&model.BillRecord{}). + q := o.db.Model(&model.BillRecord{}). Joins("JOIN accounts ON accounts.id = bill_records.account_id"). - Where("accounts.deleted_at IS NULL"). - Count(&count).Error + 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 ab7f39b..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 { @@ -61,10 +62,12 @@ type RentalInterface interface { type BillInterface interface { CreateBill(*model.BillRecord) error - ListBillsByAccount(accountID uint, limit, offset int) ([]model.BillRecord, error) - ListAllBills(limit, offset int) ([]model.BillRecord, error) - CountBillsByAccount(accountID uint) (int64, error) - CountAllBills() (int64, 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 { diff --git a/pkg/platform/client.go b/pkg/platform/client.go index b5a6b95..1490dab 100644 --- a/pkg/platform/client.go +++ b/pkg/platform/client.go @@ -66,6 +66,8 @@ 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 } @@ -94,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 { diff --git a/pkg/platform/youpin/client.go b/pkg/platform/youpin/client.go index bbab38f..0711890 100644 --- a/pkg/platform/youpin/client.go +++ b/pkg/platform/youpin/client.go @@ -226,10 +226,7 @@ func (c *Client) fetchBuyPage(ctx context.Context, page int, since int64, tradeS if len(data.OrderList) == 0 || finished { return trades, false, nil } - // API may return null total; fall back to page-full heuristic. - if data.TotalCount > 0 { - return trades, page*DefaultPageSize < data.TotalCount, nil - } + // API may return null total return trades, len(data.OrderList) == DefaultPageSize, nil } @@ -415,11 +412,17 @@ func (c *Client) GetBalance(ctx context.Context) (*platform.Balance, error) { 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) + 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) + return c.fetchBillPage(ctx, page, cfg.Since, cfg.PageSize) }, ) if err != nil { @@ -429,10 +432,13 @@ func (c *Client) GetBillHistory(ctx context.Context, opts ...platform.QueryOptio return bills, nil } -func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]platform.BillRecord, bool, error) { +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": DefaultPageSize, + "pageSize": pageSize, "Sessionid": c.deviceID, } _, respBody, err := c.doRequest(ctx, "POST", "/api/youpin/bff/payment/v1/user/userAssets/query/page/v2", nil, body) @@ -442,10 +448,12 @@ func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]pl data, err := parseYoupin[youpinBillPageData](respBody) if err != nil { - c.Log.Warn("youpin bill page failed", "page", page, "err", err) + 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 { @@ -461,11 +469,11 @@ func (c *Client) fetchBillPage(ctx context.Context, page int, since int64) ([]pl records = append(records, rec) } - hasMore := !finished && len(data.DataList) == DefaultPageSize - if data.Total > 0 { - hasMore = page*DefaultPageSize < data.Total + if len(data.DataList) == 0 || finished { + return records, false, nil } - return records, hasMore, nil + // API may return null total + return records, len(data.DataList) == pageSize, nil } // --------------------------------------------------------------------------- diff --git a/pkg/platform/youpin/convert.go b/pkg/platform/youpin/convert.go index 3e0ff92..dc42387 100644 --- a/pkg/platform/youpin/convert.go +++ b/pkg/platform/youpin/convert.go @@ -118,6 +118,8 @@ func youpinTypeToInternal(typeID int) int { return model.BillTypeRenewalRental case 43: return model.BillTypeRechargForPurchaseAccount + case 23: + return model.BillTypeWithdrawRefund case 187: return model.BillTypeRentalFee default: diff --git a/pkg/service/bill/service.go b/pkg/service/bill/service.go index 1b36bf6..86097a1 100644 --- a/pkg/service/bill/service.go +++ b/pkg/service/bill/service.go @@ -8,8 +8,22 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" ) +type BillFilter struct { + TypeID int + Platform string + StartTime int64 + EndTime int64 +} + type BillInterface interface { - List(accountID uint) ([]model.BillRecord, error) + 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 { @@ -21,11 +35,60 @@ func NewService(log *logfx.Logger, orm orm.ORMInterface) *service { return &service{log: log, orm: orm} } -func (s *service) List(accountID uint) ([]model.BillRecord, error) { +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 { - return s.orm.ListAllBills(1000, 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 s.orm.ListBillsByAccount(accountID, 1000, 0) + + return &PaginatedBills{ + Records: records, + TotalCount: total, + }, nil } var Module = fx.Module("bill", diff --git a/pkg/service/sync/engine.go b/pkg/service/sync/engine.go index 99d276e..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, billHistory, 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) @@ -102,7 +103,7 @@ func (e *Engine) SyncAccount(accountID uint) (*SyncResult, error) { 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, @@ -143,14 +144,27 @@ func (e *Engine) createAndVerify(acc *model.Account) (platform.Client, error) { } // fetchData concurrently pulls buy history, sell history, balance, and bill history. -func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( +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 } - e.log.Debug("fetching data", "since", utils.SecondsToDateTime(since, time.DateTime)) + + billSince := int64(0) + billSinceSec := int64(0) + if billLastSyncAt != nil { + billSinceSec = *billLastSyncAt + billSince = *billLastSyncAt * 1000 + } + + e.log.Debug("fetching data", + "trade_since", utils.SecondsToDateTime(tradeSinceSec, time.DateTime), + "bill_since", utils.SecondsToDateTime(billSinceSec, time.DateTime), + ) var ( buys, sells []platform.TradeRecord @@ -166,7 +180,7 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( 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)) @@ -178,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)) @@ -202,7 +216,7 @@ func (e *Engine) fetchData(client platform.Client, lastSyncAt *int64) ( go func() { defer wg.Done() - b, err := client.GetBillHistory(ctx, platform.WithSince(since)) + b, err := client.GetBillHistory(ctx, platform.WithSince(billSince)) mu.Lock() if err != nil { warnings = append(warnings, fmt.Sprintf("bill history: %v", err)) @@ -249,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 { @@ -266,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 { @@ -353,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),