diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 91369b06..cc2d3b04 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -646,6 +646,14 @@ func (CoinSellerRechargeOrder) TableName() string { return "admin_coin_seller_recharge_orders" } +// BusinessTimeMS 是财务归档和统计切日使用的订单业务时间;历史订单未保存 order_date_ms 时回退到真实录入时间。 +func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 { + if order.OrderDateMS > 0 { + return order.OrderDateMS + } + return order.CreatedAtMS +} + type UserWithdrawalApplication struct { ID uint `gorm:"primaryKey" json:"id"` AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"` diff --git a/server/admin/internal/modules/dashboard/service.go b/server/admin/internal/modules/dashboard/service.go index 7528663c..2f9d8bd6 100644 --- a/server/admin/internal/modules/dashboard/service.go +++ b/server/admin/internal/modules/dashboard/service.go @@ -293,7 +293,8 @@ func (s *DashboardService) RecordFinanceCoinSellerRechargeOrder(ctx context.Cont "target_region_id": order.TargetRegionID, "usd_minor": order.USDMinorAmount, "coin_amount": order.CoinAmount, - "created_at_ms": order.CreatedAtMS, + // 统计切日必须使用运营选择的订单业务时间;历史数据通过模型方法回退到录入时间。 + "created_at_ms": order.BusinessTimeMS(), } return s.statisticsPOST(ctx, "/internal/v1/statistics/social/coin-seller-recharge-orders", payload) } diff --git a/server/admin/internal/modules/financeorder/service.go b/server/admin/internal/modules/financeorder/service.go index 4f758343..0619f026 100644 --- a/server/admin/internal/modules/financeorder/service.go +++ b/server/admin/internal/modules/financeorder/service.go @@ -107,6 +107,11 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat if err != nil { return nil, err } + nowMS := s.now().UnixMilli() + input.OrderDateMS, err = normalizeCreateOrderBusinessTime(input.OrderDateMS, nowMS) + if err != nil { + return nil, err + } // 金币数只由当前 APP 汇率配置生成;即使调用方绕过前端直接提交 coinAmount,也不会进入请求结构或账务结果。 target, err := s.resolveCoinSellerTarget(ctx, requestID, input.AppCode, input.TargetUserID) if err != nil { @@ -128,7 +133,6 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat if err := verifyReceiptMatchesInput(input, receipt); err != nil { return nil, err } - nowMS := s.now().UnixMilli() verifiedAtMS := receipt.VerifiedAtMS if verifiedAtMS <= 0 { verifiedAtMS = nowMS @@ -935,6 +939,19 @@ func normalizeOrderDateMS(orderDate string, orderDateMS int64) (int64, error) { return 0, errors.New("订单日期不正确") } +func normalizeCreateOrderBusinessTime(orderDateMS int64, nowMS int64) (int64, error) { + if nowMS <= 0 { + return 0, errors.New("当前时间不正确") + } + if orderDateMS <= 0 { + return nowMS, nil + } + if orderDateMS > nowMS { + return 0, errors.New("订单时间不能晚于当前时间") + } + return orderDateMS, nil +} + func requiresLegacyWriter(appCode string) bool { switch appctx.Normalize(appCode) { case "aslan", "yumi": diff --git a/server/admin/internal/modules/financeorder/service_test.go b/server/admin/internal/modules/financeorder/service_test.go index 1598dc88..f30fe9d1 100644 --- a/server/admin/internal/modules/financeorder/service_test.go +++ b/server/admin/internal/modules/financeorder/service_test.go @@ -102,6 +102,19 @@ func TestNormalizeReceiptVerificationCarriesProviderCurrencyAmount(t *testing.T) } } +func TestNormalizeCreateOrderBusinessTimeDefaultsToNowAndRejectsFuture(t *testing.T) { + const nowMS = int64(1_783_900_000_000) + if got, err := normalizeCreateOrderBusinessTime(0, nowMS); err != nil || got != nowMS { + t.Fatalf("missing order time should default to now: got=%d err=%v", got, err) + } + if got, err := normalizeCreateOrderBusinessTime(nowMS-86_400_000, nowMS); err != nil || got != nowMS-86_400_000 { + t.Fatalf("past business time should be preserved: got=%d err=%v", got, err) + } + if _, err := normalizeCreateOrderBusinessTime(nowMS+1, nowMS); err == nil || !strings.Contains(err.Error(), "不能晚于") { + t.Fatalf("future business time should be rejected, got %v", err) + } +} + func TestNormalizeCreateOrderLeavesCoinAmountForServerQuote(t *testing.T) { input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{ AppCode: "lalu", diff --git a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go index 93e403a0..f17610c0 100644 --- a/server/admin/internal/modules/payment/finance_coin_seller_recharge.go +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge.go @@ -530,11 +530,11 @@ func financeCoinSellerRechargeOrderBill(order model.CoinSellerRechargeOrder) rec CoinAmount: order.CoinAmount, USDMinorAmount: order.USDMinorAmount, ProviderAmountMinor: providerAmountMinor, - CreatedAtMS: order.CreatedAtMS, + CreatedAtMS: order.BusinessTimeMS(), ProviderCode: order.ProviderCode, UserPaidCurrencyCode: currency, UserPaidAmountMicro: userPaidAmountMicro, - PaidSyncedAtMS: order.CreatedAtMS, + PaidSyncedAtMS: order.BusinessTimeMS(), } } diff --git a/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go b/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go new file mode 100644 index 00000000..196526f0 --- /dev/null +++ b/server/admin/internal/modules/payment/finance_coin_seller_recharge_test.go @@ -0,0 +1,23 @@ +package payment + +import ( + "testing" + + "hyapp-admin-server/internal/model" +) + +func TestFinanceCoinSellerRechargeOrderBillUsesBusinessTime(t *testing.T) { + order := model.CoinSellerRechargeOrder{ + ID: 7, + AppCode: "lalu", + TargetUserID: 10001, + USDMinorAmount: 15000, + CoinAmount: 12000000, + OrderDateMS: 1_783_708_200_000, + CreatedAtMS: 1_783_881_000_000, + } + bill := financeCoinSellerRechargeOrderBill(order) + if bill.CreatedAtMS != order.OrderDateMS || bill.PaidSyncedAtMS != order.OrderDateMS { + t.Fatalf("finance bill must use selected business time: %+v", bill) + } +} diff --git a/server/admin/internal/repository/coin_seller_recharge_order_repository.go b/server/admin/internal/repository/coin_seller_recharge_order_repository.go index 1499ccc0..e7c90935 100644 --- a/server/admin/internal/repository/coin_seller_recharge_order_repository.go +++ b/server/admin/internal/repository/coin_seller_recharge_order_repository.go @@ -16,6 +16,9 @@ var ( ErrCoinSellerRechargeOrderGranted = errors.New("coin seller recharge order already granted") ) +// coinSellerRechargeBusinessTimeSQL 让新订单按运营选择的业务时间归档,同时保持历史 order_date_ms=0 数据仍按录入时间查询。 +const coinSellerRechargeBusinessTimeSQL = "CASE WHEN order_date_ms > 0 THEN order_date_ms ELSE created_at_ms END" + type CoinSellerRechargeOrderListOptions struct { Page int PageSize int @@ -121,7 +124,7 @@ func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderList } var orders []model.CoinSellerRechargeOrder err := query. - Order("created_at_ms DESC, id DESC"). + Order(coinSellerRechargeBusinessTimeSQL + " DESC, id DESC"). Limit(pageSize). Offset((page - 1) * pageSize). Find(&orders).Error @@ -375,10 +378,10 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha query = query.Where("operator_user_id = ?", options.OperatorID) } if options.CreatedFromMS > 0 { - query = query.Where("created_at_ms >= ?", options.CreatedFromMS) + query = query.Where(coinSellerRechargeBusinessTimeSQL+" >= ?", options.CreatedFromMS) } if options.CreatedToMS > 0 { - query = query.Where("created_at_ms < ?", options.CreatedToMS) + query = query.Where(coinSellerRechargeBusinessTimeSQL+" < ?", options.CreatedToMS) } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" @@ -406,10 +409,10 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller query = query.Where("target_country_id = ?", options.CountryID) } if options.CreatedFromMS > 0 { - query = query.Where("created_at_ms >= ?", options.CreatedFromMS) + query = query.Where(coinSellerRechargeBusinessTimeSQL+" >= ?", options.CreatedFromMS) } if options.CreatedToMS > 0 { - query = query.Where("created_at_ms < ?", options.CreatedToMS) + query = query.Where(coinSellerRechargeBusinessTimeSQL+" < ?", options.CreatedToMS) } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" @@ -434,9 +437,9 @@ func (s *Store) scanCoinSellerRechargeOrderDailyStats(options CoinSellerRecharge } var rows []row tzOffsetMS := int64(options.TzOffsetMinutes) * 60_000 - // 财务筛选按 created_at_ms 取数;日趋势按前端传入的统计时区偏移切日,避免北京时间财务页跨日错桶。 + // 财务筛选和趋势都按订单业务时间取数;历史记录自动回退录入时间,并继续按前端统计时区偏移切日。 if err := query.Select(` - DATE_FORMAT(FROM_UNIXTIME((created_at_ms + ?) / 1000), '%Y-%m-%d') AS date, + DATE_FORMAT(FROM_UNIXTIME((`+coinSellerRechargeBusinessTimeSQL+` + ?) / 1000), '%Y-%m-%d') AS date, COUNT(*) AS count, COALESCE(SUM(usd_minor_amount), 0) AS usd_minor, COALESCE(SUM(coin_amount), 0) AS coin_amount`, tzOffsetMS).