From f56b84bb108368ecfd56fb3a030a98a35cef98dd Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 23 Oct 2026 00:13:07 +0800 Subject: [PATCH] fix: display finance records and ledgers in Beijing time --- .../store/finance_time_integration_test.go | 174 ++++++++++++++++++ internal/store/recharges.go | 11 +- internal/store/tasks.go | 2 +- internal/store/time.go | 20 ++ internal/store/wallet_ledgers.go | 2 +- internal/store/withdrawals.go | 11 +- web/src/mobile/api.test.ts | 7 +- web/src/mobile/api.ts | 9 + web/src/mobile/views/WalletLedgersView.vue | 18 +- 9 files changed, 224 insertions(+), 30 deletions(-) create mode 100644 internal/store/finance_time_integration_test.go diff --git a/internal/store/finance_time_integration_test.go b/internal/store/finance_time_integration_test.go new file mode 100644 index 0000000..23f047f --- /dev/null +++ b/internal/store/finance_time_integration_test.go @@ -0,0 +1,174 @@ +package store + +import ( + "context" + "os" + "testing" + + "github.com/jmoiron/sqlx" +) + +func financeTimeTestDB(t *testing.T) *sqlx.DB { + t.Helper() + dsn := os.Getenv("OLONS_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set OLONS_TEST_MYSQL_DSN to run the finance time integration tests") + } + db, err := sqlx.Connect("mysql", dsn) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestFinanceRecordTimesUseCorrectSourceZone(t *testing.T) { + db := financeTimeTestDB(t) + // Temporary tables shadow only this connection's tables; no business rows are changed. + for _, query := range []string{ + `CREATE TEMPORARY TABLE hg_payment_channel (id BIGINT,tenant_id BIGINT,name VARCHAR(64))`, + `CREATE TEMPORARY TABLE hg_recharge_order ( + id BIGINT,tenant_id BIGINT,member_id BIGINT,recharge_no VARCHAR(64),channel_id BIGINT, + request_amount BIGINT DEFAULT 50000,paid_amount BIGINT DEFAULT 50000,currency VARCHAR(8) DEFAULT 'CNY', + voucher_attachment_ids JSON,recharge_status INT DEFAULT 3,remark VARCHAR(255) DEFAULT '', + review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '', + paid_at DATETIME,reviewed_at DATETIME,credited_at DATETIME,created_at DATETIME,updated_at DATETIME)`, + `CREATE TEMPORARY TABLE hg_withdrawal_order ( + id BIGINT,tenant_id BIGINT,member_id BIGINT,withdrawal_no VARCHAR(64),payment_account_id BIGINT DEFAULT 1, + channel_id BIGINT,request_amount BIGINT DEFAULT 50000,fee_amount BIGINT DEFAULT 0,actual_amount BIGINT DEFAULT 50000, + currency VARCHAR(8) DEFAULT 'CNY',account_mask VARCHAR(64) DEFAULT '',bank_name VARCHAR(64) DEFAULT '', + holder_mask VARCHAR(64) DEFAULT '',risk_result JSON,withdrawal_status INT DEFAULT 5, + review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '', + reviewed_at DATETIME,processed_at DATETIME,completed_at DATETIME,created_at DATETIME,updated_at DATETIME)`, + } { + if _, err := db.Exec(query); err != nil { + t.Fatal(err) + } + } + s := &Store{db: db} + ctx := context.Background() + for _, test := range []struct{ zone, createdAt string }{ + {"+00:00", "2026-10-22 15:59:00"}, + {"+08:00", "2026-10-22 23:59:00"}, + } { + t.Run(test.zone, func(t *testing.T) { + for _, query := range []string{`DELETE FROM hg_recharge_order`, `DELETE FROM hg_withdrawal_order`} { + if _, err := db.Exec(query); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO hg_recharge_order + (id,tenant_id,member_id,recharge_no,channel_id,created_at,updated_at,paid_at,reviewed_at,credited_at) + VALUES(1,0,9,'RT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'), + (2,0,9,'RT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO hg_withdrawal_order + (id,tenant_id,member_id,withdrawal_no,channel_id,created_at,updated_at,reviewed_at,processed_at,completed_at) + VALUES(1,0,9,'WT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'), + (2,0,9,'WT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil { + t.Fatal(err) + } + recharges, err := s.RechargeOrders(ctx, 0, 9, 0, 0, 50) + if err != nil || recharges == nil || len(recharges.Items) != 2 { + t.Fatalf("recharges = %+v, err = %v", recharges, err) + } + withdrawals, err := s.WithdrawalOrders(ctx, 0, 9, 0, 0, 50) + if err != nil || withdrawals == nil || len(withdrawals.Items) != 2 { + t.Fatalf("withdrawals = %+v, err = %v", withdrawals, err) + } + for _, item := range recharges.Items { + if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) { + t.Errorf("recharge list time = %+v", item) + } + } + for _, item := range withdrawals.Items { + if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) { + t.Errorf("withdrawal list time = %+v", item) + } + } + recharge, err := s.RechargeOrder(ctx, 0, 9, 1) + if err != nil { + t.Fatal(err) + } + if recharge.PaidAt != "2026-10-22T16:01:00Z" || recharge.ReviewedAt != recharge.PaidAt || recharge.CreditedAt != recharge.PaidAt { + t.Errorf("recharge review timestamps = %+v", recharge) + } + withdrawal, err := s.WithdrawalOrder(ctx, 0, 9, 1) + if err != nil { + t.Fatal(err) + } + if withdrawal.ReviewedAt != "2026-10-22T16:01:00Z" || withdrawal.ProcessedAt != withdrawal.ReviewedAt || withdrawal.CompletedAt != withdrawal.ReviewedAt { + t.Errorf("withdrawal review timestamps = %+v", withdrawal) + } + recharge, err = s.RechargeOrder(ctx, 0, 9, 2) + if err != nil || recharge == nil || recharge.PaidAt != "" || recharge.ReviewedAt != "" || recharge.CreditedAt != "" { + t.Errorf("empty recharge review times = %+v, err = %v", recharge, err) + } + withdrawal, err = s.WithdrawalOrder(ctx, 0, 9, 2) + if err != nil || withdrawal == nil || withdrawal.ReviewedAt != "" || withdrawal.ProcessedAt != "" || withdrawal.CompletedAt != "" { + t.Errorf("empty withdrawal review times = %+v, err = %v", withdrawal, err) + } + }) + } +} + +func TestWalletAndOrderLedgerTimesKeepUTCAndBeijingWriters(t *testing.T) { + db := financeTimeTestDB(t) + if _, err := db.Exec(`CREATE TEMPORARY TABLE hg_wallet_ledger ( + id BIGINT,tenant_id BIGINT DEFAULT 0,member_id BIGINT DEFAULT 9,ledger_no VARCHAR(64), + biz_type VARCHAR(64) DEFAULT 'commission',biz_no VARCHAR(64) DEFAULT 'ORDER1',entry_type VARCHAR(64) DEFAULT 'available', + direction INT DEFAULT 1,amount BIGINT DEFAULT 100,before_amount BIGINT DEFAULT 1000,after_amount BIGINT DEFAULT 1100, + currency VARCHAR(8) DEFAULT 'CNY',remark VARCHAR(255) DEFAULT '',idempotency_key VARCHAR(128),occurred_at DATETIME)`); err != nil { + t.Fatal(err) + } + fixtures := []struct{ key, remark, occurredAt, want string }{ + {"task:1:settle:commission", "任务完成自动返佣", "2026-10-22 16:00:07", "2026-10-22T16:00:07Z"}, + {"recharge:2", "储入审核到账", "2026-10-22 23:59:07", "2026-10-22T15:59:07Z"}, + {"withdrawal:W1:available", "申请取出冻结", "2026-10-22 15:58:07", "2026-10-22T15:58:07Z"}, + {"withdraw:1:consume", "取出审核", "2026-10-22 23:57:07", "2026-10-22T15:57:07Z"}, + {"task:2:admin-match:commission", "后台匹配订单完成返佣", "2026-10-22 23:56:07", "2026-10-22T15:56:07Z"}, + {"task:3:match:available", "自动派单扣除可用余额", "2026-10-22 15:55:07", "2026-10-22T15:55:07Z"}, + {"task:4:match:available", "手动订单强制付款扣除可用余额", "2026-10-22 23:54:07", "2026-10-22T15:54:07Z"}, + {"task:5:match:frozen", "会员确认手动订单冻结本金", "2026-10-22 15:53:07", "2026-10-22T15:53:07Z"}, + {"task:6:expire:available", "订单超时退回订单本金", "2026-10-22 15:52:07", "2026-10-22T15:52:07Z"}, + } + for i, fixture := range fixtures { + if _, err := db.Exec(`INSERT INTO hg_wallet_ledger (id,ledger_no,idempotency_key,remark,occurred_at) VALUES(?,?,?,?,?)`, + i+1, fixture.key, fixture.key, fixture.remark, fixture.occurredAt); err != nil { + t.Fatal(err) + } + } + for _, zone := range []string{"+00:00", "+08:00"} { + t.Run(zone, func(t *testing.T) { + if _, err := db.Exec(`SET SESSION time_zone=?`, zone); err != nil { + t.Fatal(err) + } + s := &Store{db: db} + page, err := s.WalletLedgers(context.Background(), 0, 9, "", 0, 0, 50) + if err != nil || page == nil || len(page.Items) != len(fixtures) { + t.Fatalf("ledger page = %+v, err = %v", page, err) + } + for i, fixture := range fixtures { + if page.Items[i].LedgerNo != fixture.key || page.Items[i].OccurredAt != fixture.want { + t.Errorf("ledger %d = %+v, want %s at %s", i, page.Items[i], fixture.key, fixture.want) + } + } + rows, err := s.OrderLedgers(context.Background(), 0, 9, []string{"ORDER1"}) + if err != nil || len(rows) != len(fixtures) { + t.Fatalf("order ledgers = %+v, err = %v", rows, err) + } + for i, row := range rows { + fixture := fixtures[len(fixtures)-i-1] + if row["ledger_no"] != fixture.key || row["occurred_at"] != fixture.want { + t.Errorf("order ledger = %+v, want %s at %s", row, fixture.key, fixture.want) + } + } + }) + } +} diff --git a/internal/store/recharges.go b/internal/store/recharges.go index 009fcc1..5f81cfd 100644 --- a/internal/store/recharges.go +++ b/internal/store/recharges.go @@ -160,10 +160,11 @@ func rechargeSelectSQL() string { return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount, o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids, o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no, - COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at, - COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, - COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at, - DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, - DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at + COALESCE(` + beijingWallTimeUTC("o.paid_at") + `,'') AS paid_at, + COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at, + COALESCE(` + beijingWallTimeUTC("o.credited_at") + `,'') AS credited_at, + ` + databaseWallTimeUTC("o.created_at") + ` AS created_at, + CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + ` + ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` } diff --git a/internal/store/tasks.go b/internal/store/tasks.go index d7e506f..cae77e3 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -815,7 +815,7 @@ func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, busi return []map[string]any{}, nil } query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark, - DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger + `+walletLedgerTimeUTC()+` AS occurred_at FROM hg_wallet_ledger WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers) if err != nil { return nil, err diff --git a/internal/store/time.go b/internal/store/time.go index 6ea49cc..50c447b 100644 --- a/internal/store/time.go +++ b/internal/store/time.go @@ -6,3 +6,23 @@ package store func databaseWallTimeUTC(column string) string { return `DATE_FORMAT(TIMESTAMPADD(SECOND,TIMESTAMPDIFF(SECOND,NOW(),UTC_TIMESTAMP()),` + column + `),'%Y-%m-%dT%H:%i:%sZ')` } + +// beijingWallTimeUTC handles DATETIME fields explicitly written by the admin +// service, whose business clock is Asia/Shanghai, independently of the DB session. +func beijingWallTimeUTC(column string) string { + return `DATE_FORMAT(TIMESTAMPADD(HOUR,-8,` + column + `),'%Y-%m-%dT%H:%i:%sZ')` +} + +// walletLedgerTimeUTC preserves the UTC clock used by insertTaskLedger and +// insertWithdrawalLedger; all other ledger writers use the admin's Beijing clock. +// Legacy forced-payment entries share the task:...:match key with client entries, +// so their writer-provided remarks must also distinguish those two sources. +func walletLedgerTimeUTC() string { + return `CASE WHEN idempotency_key LIKE 'withdrawal:%' + OR idempotency_key REGEXP '^task:[0-9]+:(settle|expire):' + OR (idempotency_key REGEXP '^task:[0-9]+:match:(available|frozen)$' AND remark IN ( + '自动派单扣除可用余额','自动派单冻结订单本金', + '会员确认手动订单扣除可用余额','会员确认手动订单冻结本金')) + THEN DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') + ELSE ` + beijingWallTimeUTC("occurred_at") + ` END` +} diff --git a/internal/store/wallet_ledgers.go b/internal/store/wallet_ledgers.go index 014f017..b15c1fa 100644 --- a/internal/store/wallet_ledgers.go +++ b/internal/store/wallet_ledgers.go @@ -44,7 +44,7 @@ func (s *Store) WalletLedgers(ctx context.Context, tenantID, memberID int64, biz items := make([]WalletLedger, 0) err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction, amount,before_amount,after_amount,currency,remark, - DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at + `+walletLedgerTimeUTC()+` AS occurred_at FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...) return &WalletLedgerPage{Items: items, Total: total}, err } diff --git a/internal/store/withdrawals.go b/internal/store/withdrawals.go index 022bf51..d2303b6 100644 --- a/internal/store/withdrawals.go +++ b/internal/store/withdrawals.go @@ -581,11 +581,12 @@ func withdrawalSelectSQL() string { o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask, COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code, o.review_reason,o.external_no, - COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, - COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at, - COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, - DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, - DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at + COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at, + COALESCE(` + beijingWallTimeUTC("o.processed_at") + `,'') AS processed_at, + COALESCE(` + beijingWallTimeUTC("o.completed_at") + `,'') AS completed_at, + ` + databaseWallTimeUTC("o.created_at") + ` AS created_at, + CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + ` + ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` } diff --git a/web/src/mobile/api.test.ts b/web/src/mobile/api.test.ts index d3d64e8..92c9ec4 100644 --- a/web/src/mobile/api.test.ts +++ b/web/src/mobile/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dateTime } from './api'; +import { dateTime, dateTimeSeconds } from './api'; describe('Beijing business time display', () => { it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])( @@ -11,6 +11,9 @@ describe('Beijing business time display', () => { expect(dateTime('2026-10-22T13:06:00Z')).toBe('2026-10-22 21:06'); expect(dateTime('2026-10-22T21:06:00+08:00')).toBe('2026-10-22 21:06'); expect(dateTime('2026-10-22T16:00:00Z')).toBe('2026-10-23 00:00'); + expect(dateTimeSeconds('2026-10-22T13:06:07Z')).toBe('2026-10-22 21:06:07'); + expect(dateTimeSeconds('2026-10-22T21:06:07+08:00')).toBe('2026-10-22 21:06:07'); + expect(dateTimeSeconds('2026-10-22T16:00:07Z')).toBe('2026-10-23 00:00:07'); } finally { if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; @@ -21,5 +24,7 @@ describe('Beijing business time display', () => { it('uses a placeholder for missing or invalid timestamps', () => { expect(dateTime()).toBe('--'); expect(dateTime('invalid-time')).toBe('--'); + expect(dateTimeSeconds()).toBe('--'); + expect(dateTimeSeconds('invalid-time')).toBe('--'); }); }); diff --git a/web/src/mobile/api.ts b/web/src/mobile/api.ts index edaf466..1dd0c1f 100644 --- a/web/src/mobile/api.ts +++ b/web/src/mobile/api.ts @@ -180,6 +180,14 @@ export function money(value?: number, currency = 'CNY') { } export function dateTime(value?: string) { + return formatBeijingDateTime(value, false); +} + +export function dateTimeSeconds(value?: string) { + return formatBeijingDateTime(value, true); +} + +function formatBeijingDateTime(value: string | undefined, withSeconds: boolean) { if (!value) return '--'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '--'; @@ -190,6 +198,7 @@ export function dateTime(value?: string) { day: '2-digit', hour: '2-digit', minute: '2-digit', + ...(withSeconds ? { second: '2-digit' as const } : {}), hourCycle: 'h23', }) .format(date) diff --git a/web/src/mobile/views/WalletLedgersView.vue b/web/src/mobile/views/WalletLedgersView.vue index e5d9dee..267e949 100644 --- a/web/src/mobile/views/WalletLedgersView.vue +++ b/web/src/mobile/views/WalletLedgersView.vue @@ -38,7 +38,7 @@ import { onMounted, ref } from 'vue'; import PageHeader from '../components/PageHeader.vue'; import AsyncState from '../components/AsyncState.vue'; - import { errorText, request } from '../api'; + import { dateTimeSeconds, errorText, request } from '../api'; type Direction = 'all' | 'income' | 'expense'; const tabs: Array<{ label: string; value: Direction }> = [ @@ -64,22 +64,6 @@ const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2); const entryText = (value: string) => entryLabels[value] || '账户余额变动'; - function dateTimeSeconds(value?: string) { - if (!value) return '--'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '--'; - return new Intl.DateTimeFormat('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }) - .format(date) - .replaceAll('/', '-'); - } async function load() { loading.value = true; error.value = '';