fix: display finance records and ledgers in Beijing time
Deploy olons-server production / deploy (push) Successful in 34s
Deploy olons-server production / deploy (push) Successful in 34s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
|
||||
@@ -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('--');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user