fix: display finance records and ledgers in Beijing time
Deploy olons-server production / deploy (push) Successful in 34s

This commit is contained in:
richjohnrambo
2026-10-23 00:13:07 +08:00
parent 8ce67648b1
commit f56b84bb10
9 changed files with 224 additions and 30 deletions
@@ -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)
}
}
})
}
}
+6 -5
View File
@@ -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`
}
+1 -1
View File
@@ -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
+20
View File
@@ -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`
}
+1 -1
View File
@@ -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
}
+6 -5
View File
@@ -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`
}
+6 -1
View File
@@ -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('--');
});
});
+9
View File
@@ -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)
+1 -17
View File
@@ -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 = '';