diff --git a/server/admin/internal/modules/coinledger/dto.go b/server/admin/internal/modules/coinledger/dto.go index a8c97240..123f5295 100644 --- a/server/admin/internal/modules/coinledger/dto.go +++ b/server/admin/internal/modules/coinledger/dto.go @@ -43,6 +43,11 @@ type coinSellerLedgerDTO struct { Direction string `json:"direction"` AvailableDelta int64 `json:"availableDelta"` SellerBalanceAfter int64 `json:"sellerBalanceAfter"` + StockType string `json:"stockType,omitempty"` + PaidCurrencyCode string `json:"paidCurrencyCode,omitempty"` + PaidAmountMicro int64 `json:"paidAmountMicro,omitempty"` + TransferUSDMinor int64 `json:"transferUsdMinor,omitempty"` + Reason string `json:"reason,omitempty"` CounterpartyUserID string `json:"counterpartyUserId"` OperatorUserID string `json:"operatorUserId"` Operator coinAdjustmentOperatorDTO `json:"operator"` diff --git a/server/admin/internal/modules/coinledger/service.go b/server/admin/internal/modules/coinledger/service.go index caf8e1b8..50db06a6 100644 --- a/server/admin/internal/modules/coinledger/service.go +++ b/server/admin/internal/modules/coinledger/service.go @@ -167,7 +167,7 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu } var buf bytes.Buffer writer := csv.NewWriter(&buf) - _ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "转账金额", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"}) + _ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "金币变动", "转账USD金额", "USDT数量", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"}) for _, item := range items { operatorName, operatorID := coinSellerLedgerOperatorExportFields(item) _ = writer.Write([]string{ @@ -176,6 +176,8 @@ func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, qu item.Seller.UserID, coinSellerLedgerLabel(item), strconv.FormatInt(signedCoinSellerLedgerAmount(item), 10), + formatUSDMinorForCSV(item.TransferUSDMinor), + formatUSDTMicroForCSV(item.PaidAmountMicro), item.Receiver.Username, item.Receiver.DisplayUserID, item.Receiver.UserID, @@ -276,6 +278,11 @@ func (s *Service) listCoinSellerLedger(ctx context.Context, appCode string, quer return nil, 0, err } operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId") + item.StockType = firstNonEmpty(metadataString(metadata, "stock_type"), metadataString(metadata, "stockType")) + item.PaidCurrencyCode = firstNonEmpty(metadataString(metadata, "paid_currency_code"), metadataString(metadata, "paidCurrencyCode")) + item.PaidAmountMicro = metadataInt64(metadata, "paid_amount_micro", "paidAmountMicro") + item.TransferUSDMinor = metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor", "transfer_usd_minor", "transferUsdMinor") + item.Reason = metadataString(metadata, "reason") // 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id,老数据没有 metadata 时再用 counterparty_user_id; // 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。 receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata) @@ -771,16 +778,17 @@ func normalizeCoinSellerLedgerQuery(query coinSellerLedgerQuery) coinSellerLedge func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) { switch strings.TrimSpace(ledgerType) { case "": - // 空类型表示“全部币商流水”,但仍只允许当前产品定义的三个类型口径,不能把其他币商库存内部账带出来。 + // 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。 return []string{ coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType, coinSellerTransferBizType, salaryTransferToCoinSellerBizType, + coinManualCreditBizType, }, nil case coinSellerLedgerTypeAdminStockCredit: - // 后台入账是运营口径,底层包含正常进货和金币补偿两种账务 biz_type。 - return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType}, nil + // 后台操作是运营口径,底层包含 USDT 进货、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。 + return []string{coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil case coinSellerLedgerTypeSellerTransfer: // 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。 return []string{coinSellerTransferBizType}, nil @@ -796,6 +804,8 @@ func coinSellerLedgerTypeForBizType(bizType string) string { switch bizType { case coinSellerStockPurchaseBizType, coinSellerCoinCompensationBizType: return coinSellerLedgerTypeAdminStockCredit + case coinManualCreditBizType: + return coinSellerLedgerTypeAdminStockCredit case coinSellerTransferBizType: return coinSellerLedgerTypeSellerTransfer case salaryTransferToCoinSellerBizType: @@ -806,26 +816,30 @@ func coinSellerLedgerTypeForBizType(bizType string) string { } func coinSellerLedgerLabel(item coinSellerLedgerDTO) string { + switch item.BizType { + case coinSellerStockPurchaseBizType: + return "USDT进货" + case coinSellerCoinCompensationBizType: + return "金币补偿" + case coinManualCreditBizType: + if item.AvailableDelta < 0 { + return "金币扣除" + } + return "金币增加" + case coinSellerTransferBizType: + return "币商转用户" + case salaryTransferToCoinSellerBizType: + return "工资转币商" + } switch item.LedgerType { case coinSellerLedgerTypeAdminStockCredit: - return "后台入账" + return "后台操作" case coinSellerLedgerTypeSellerTransfer: return "币商转用户" case coinSellerLedgerTypeSalaryTransferIncome: return "工资转币商" default: - switch item.BizType { - case coinSellerStockPurchaseBizType: - return "币商进货" - case coinSellerCoinCompensationBizType: - return "金币补偿" - case coinSellerTransferBizType: - return "币商转用户" - case salaryTransferToCoinSellerBizType: - return "工资转币商" - default: - return firstNonEmpty(item.LedgerType, item.BizType, "-") - } + return firstNonEmpty(item.LedgerType, item.BizType, "-") } } @@ -836,6 +850,21 @@ func signedCoinSellerLedgerAmount(item coinSellerLedgerDTO) int64 { return absInt64(item.Amount) } +func formatUSDMinorForCSV(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatFloat(float64(value)/100, 'f', 2, 64) +} + +func formatUSDTMicroForCSV(value int64) string { + if value <= 0 { + return "" + } + formatted := strconv.FormatFloat(float64(value)/1_000_000, 'f', 6, 64) + return strings.TrimRight(strings.TrimRight(formatted, "0"), ".") +} + func coinSellerLedgerOperatorExportFields(item coinSellerLedgerDTO) (string, string) { if item.LedgerType != coinSellerLedgerTypeAdminStockCredit { return "", "" diff --git a/server/admin/internal/modules/coinledger/service_test.go b/server/admin/internal/modules/coinledger/service_test.go index 88b8c77d..7d3750c6 100644 --- a/server/admin/internal/modules/coinledger/service_test.go +++ b/server/admin/internal/modules/coinledger/service_test.go @@ -51,10 +51,10 @@ func TestCoinSellerLedgerWhereMapsAdminStockCredit(t *testing.T) { if err != nil { t.Fatalf("coin seller ledger where failed: %v", err) } - if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?) AND e.user_id IN (?,?)"; where != want { + if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?) AND e.user_id IN (?,?)"; where != want { t.Fatalf("where mismatch:\nwant %s\n got %s", want, where) } - if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != int64(3001) || args[5] != int64(3002) { + if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinManualCreditBizType || args[5] != int64(3001) || args[6] != int64(3002) { t.Fatalf("args mismatch: %#v", args) } } @@ -64,10 +64,10 @@ func TestCoinSellerLedgerWhereEmptyTypeUsesAllPublicTypes(t *testing.T) { if err != nil { t.Fatalf("coin seller ledger where failed: %v", err) } - if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?)"; where != want { + if want := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type IN (?,?,?,?,?)"; where != want { t.Fatalf("where mismatch:\nwant %s\n got %s", want, where) } - if len(args) != 6 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType { + if len(args) != 7 || args[0] != "lalu" || args[1] != coinSellerAssetType || args[2] != coinSellerStockPurchaseBizType || args[3] != coinSellerCoinCompensationBizType || args[4] != coinSellerTransferBizType || args[5] != salaryTransferToCoinSellerBizType || args[6] != coinManualCreditBizType { t.Fatalf("args mismatch: %#v", args) } } @@ -110,6 +110,33 @@ func TestCoinSellerLedgerOperatorExportFieldsOnlyForAdminStock(t *testing.T) { } } +func TestCoinSellerLedgerLabelUsesConcreteStockAndDebitType(t *testing.T) { + cases := []struct { + name string + item coinSellerLedgerDTO + want string + }{ + {name: "usdt purchase", item: coinSellerLedgerDTO{BizType: coinSellerStockPurchaseBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "USDT进货"}, + {name: "compensation", item: coinSellerLedgerDTO{BizType: coinSellerCoinCompensationBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit}, want: "金币补偿"}, + {name: "debit", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: -100}, want: "金币扣除"}, + {name: "manual increase", item: coinSellerLedgerDTO{BizType: coinManualCreditBizType, LedgerType: coinSellerLedgerTypeAdminStockCredit, AvailableDelta: 100}, want: "金币增加"}, + } + for _, tc := range cases { + if got := coinSellerLedgerLabel(tc.item); got != tc.want { + t.Fatalf("%s label mismatch: got %q want %q", tc.name, got, tc.want) + } + } +} + +func TestCoinSellerLedgerCSVAmountFormatters(t *testing.T) { + if got := formatUSDMinorForCSV(50); got != "0.50" { + t.Fatalf("usd minor format mismatch: %q", got) + } + if got := formatUSDTMicroForCSV(1_230_000); got != "1.23" { + t.Fatalf("usdt micro format mismatch: %q", got) + } +} + func TestNormalizeCoinSellerLedgerQueryCapsPageSize(t *testing.T) { query := normalizeCoinSellerLedgerQuery(coinSellerLedgerQuery{Page: -1, PageSize: 1000, SellerKeyword: " 164425 ", LedgerType: " seller_transfer "}) if query.Page != 1 || query.PageSize != 100 || query.SellerKeyword != "164425" || query.LedgerType != coinSellerLedgerTypeSellerTransfer { diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index d097740f..ebcd4a28 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -276,6 +276,27 @@ func (h *Handler) CreditCoinSellerStock(c *gin.Context) { response.Created(c, coinSellerStockCreditFromProto(result)) } +func (h *Handler) DebitCoinSellerStock(c *gin.Context) { + sellerUserID, ok := parseInt64ID(c, "user_id") + if !ok { + return + } + var req coinSellerStockDebitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "币商扣除参数不正确") + return + } + result, err := h.service.DebitCoinSellerStock(c.Request.Context(), adminActorID(c), sellerUserID, middleware.CurrentRequestID(c), req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-debit", "wallet_entries", sellerUserID, + fmt.Sprintf("command_id=%s seller_user_id=%d coin_amount=%d transaction_id=%s reason=%q", + strings.TrimSpace(req.CommandID), sellerUserID, req.CoinAmount, result.GetTransactionId(), strings.TrimSpace(req.Reason))) + response.Created(c, coinSellerStockDebitFromProto(sellerUserID, req.CoinAmount, result)) +} + func (h *Handler) GetCoinSellerSalaryRates(c *gin.Context) { regionID, ok := parseInt64ID(c, "region_id") if !ok { diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index f2d599b3..93d59b26 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -103,6 +103,13 @@ type coinSellerStockCreditRequest struct { Reason string `json:"reason"` } +type coinSellerStockDebitRequest struct { + CommandID string `json:"commandId" binding:"required"` + CoinAmount int64 `json:"coinAmount" binding:"required"` + EvidenceRef string `json:"evidenceRef"` + Reason string `json:"reason" binding:"required"` +} + type replaceCoinSellerSalaryRatesRequest struct { Tiers []coinSellerSalaryRateTierRequest `json:"tiers" binding:"required"` } diff --git a/server/admin/internal/modules/hostorg/response.go b/server/admin/internal/modules/hostorg/response.go index a9ecef0e..df08b5e6 100644 --- a/server/admin/internal/modules/hostorg/response.go +++ b/server/admin/internal/modules/hostorg/response.go @@ -14,6 +14,14 @@ type coinSellerStockCreditResponse struct { CreatedAtMS int64 `json:"createdAtMs"` } +type coinSellerStockDebitResponse struct { + TransactionID string `json:"transactionId"` + SellerUserID int64 `json:"sellerUserId,string"` + CoinAmount int64 `json:"coinAmount"` + AvailableDelta int64 `json:"availableDelta"` + BalanceAfter int64 `json:"balanceAfter"` +} + func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockResponse) coinSellerStockCreditResponse { if item == nil { return coinSellerStockCreditResponse{} @@ -30,3 +38,20 @@ func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockRes CreatedAtMS: item.GetCreatedAtMs(), } } + +func coinSellerStockDebitFromProto(sellerUserID int64, coinAmount int64, item *walletv1.AdminCreditAssetResponse) coinSellerStockDebitResponse { + if item == nil { + return coinSellerStockDebitResponse{SellerUserID: sellerUserID, CoinAmount: coinAmount, AvailableDelta: -coinAmount} + } + balanceAfter := int64(0) + if item.GetBalance() != nil { + balanceAfter = item.GetBalance().GetAvailableAmount() + } + return coinSellerStockDebitResponse{ + TransactionID: item.GetTransactionId(), + SellerUserID: sellerUserID, + CoinAmount: coinAmount, + AvailableDelta: -coinAmount, + BalanceAfter: balanceAfter, + } +} diff --git a/server/admin/internal/modules/hostorg/routes.go b/server/admin/internal/modules/hostorg/routes.go index 5485c916..31694367 100644 --- a/server/admin/internal/modules/hostorg/routes.go +++ b/server/admin/internal/modules/hostorg/routes.go @@ -27,6 +27,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller) protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus) protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock) + protected.POST("/admin/coin-sellers/:user_id/stock-debits", middleware.RequirePermission("coin-seller:stock-credit"), h.DebitCoinSellerStock) protected.GET("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.GetCoinSellerSalaryRates) protected.PUT("/admin/coin-seller-salary-rates/:region_id", middleware.RequirePermission("coin-seller:exchange-rate"), h.ReplaceCoinSellerSalaryRates) } diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index 31d7ebe7..b420e899 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -293,6 +293,68 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell return nil, fmt.Errorf("stock type is invalid") } + seller, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID) + if err != nil { + return nil, err + } + + return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ + CommandId: commandID, + SellerUserId: sellerUserID, + SellerCountryId: seller.CountryID, + SellerRegionId: seller.RegionID, + StockType: stockType, + CoinAmount: req.CoinAmount, + PaidCurrencyCode: paidCurrencyCode, + PaidAmountMicro: paidAmountMicro, + PaymentRef: paymentRef, + EvidenceRef: evidenceRef, + OperatorUserId: actorID, + Reason: reason, + AppCode: appctx.FromContext(ctx), + }) +} + +func (s *Service) DebitCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockDebitRequest) (*walletv1.AdminCreditAssetResponse, error) { + if s.walletClient == nil { + return nil, fmt.Errorf("wallet client is not configured") + } + if sellerUserID <= 0 || actorID <= 0 { + return nil, fmt.Errorf("seller_user_id and operator_user_id are required") + } + commandID := strings.TrimSpace(req.CommandID) + reason := strings.TrimSpace(req.Reason) + evidenceRef := strings.TrimSpace(req.EvidenceRef) + if commandID == "" || req.CoinAmount <= 0 { + return nil, fmt.Errorf("command_id and coin_amount are required") + } + if reason == "" { + return nil, fmt.Errorf("reason is required") + } + if len(reason) > 512 || len(commandID) > 128 || len(evidenceRef) > 256 { + return nil, fmt.Errorf("coin seller debit text fields are too long") + } + if _, err := s.activeCoinSellerUser(ctx, sellerUserID, requestID); err != nil { + return nil, err + } + // 币商扣除是运营对币商专用库存的负向调账:只扣 COIN_SELLER_COIN,不写普通 COIN,也不写进货记录。 + // wallet-service 的 AdminCreditAsset 负责余额不足校验、幂等冲突检测、wallet_entries 和 outbox 原子落账。 + return s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{ + CommandId: commandID, + TargetUserId: sellerUserID, + AssetType: coinSellerMerchantAssetType, + Amount: -req.CoinAmount, + OperatorUserId: actorID, + Reason: reason, + EvidenceRef: evidenceRef, + AppCode: appctx.FromContext(ctx), + }) +} + +func (s *Service) activeCoinSellerUser(ctx context.Context, sellerUserID int64, requestID string) (*userclient.User, error) { + if s == nil || s.userClient == nil { + return nil, fmt.Errorf("user client is not configured") + } profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{ RequestID: requestID, Caller: "hyapp-admin-server", @@ -315,22 +377,7 @@ func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sell if seller == nil || seller.CountryID <= 0 || seller.RegionID <= 0 { return nil, fmt.Errorf("coin seller country and region are required") } - - return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{ - CommandId: commandID, - SellerUserId: sellerUserID, - SellerCountryId: seller.CountryID, - SellerRegionId: seller.RegionID, - StockType: stockType, - CoinAmount: req.CoinAmount, - PaidCurrencyCode: paidCurrencyCode, - PaidAmountMicro: paidAmountMicro, - PaymentRef: paymentRef, - EvidenceRef: evidenceRef, - OperatorUserId: actorID, - Reason: reason, - AppCode: appctx.FromContext(ctx), - }) + return seller, nil } func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) { diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 6d728370..c05e3df3 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -232,6 +232,7 @@ type WalletHandlers struct { ListCoinSellers http.HandlerFunc TransferCoinFromSeller http.HandlerFunc GetSalaryWalletOverview http.HandlerFunc + GetSalaryWalletHistory http.HandlerFunc SearchSalaryWalletSeller http.HandlerFunc ExchangeSalaryToCoin http.HandlerFunc TransferSalaryToSeller http.HandlerFunc @@ -555,6 +556,7 @@ func (r routes) registerWalletRoutes() { r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers) r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller) r.profile("/salary-wallet/overview", http.MethodGet, h.GetSalaryWalletOverview) + r.profile("/salary-wallet/history", http.MethodGet, h.GetSalaryWalletHistory) r.profile("/salary-wallet/coin-sellers/search", http.MethodGet, h.SearchSalaryWalletSeller) r.profile("/salary-wallet/exchange", http.MethodPost, h.ExchangeSalaryToCoin) r.profile("/salary-wallet/transfer-to-coin-seller", http.MethodPost, h.TransferSalaryToSeller) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 1ecfe8ff..80c542b9 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -4099,6 +4099,247 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) { } } +func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) { + hostClient := &fakeUserHostClient{ + hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}, + listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ + { + UserId: 42, + DisplayUserId: "163006", + Username: "self seller", + RegionId: 30, + Status: "active", + MerchantAssetType: "COIN_SELLER_COIN", + }, + }}, + } + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/coin-sellers/search?identity=HOST&display_user_id=163006", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-salary-search-self") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 { + t.Fatalf("salary wallet seller search request mismatch: %+v", hostClient.lastListCoinSellers) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data := response.Data.(map[string]any) + seller := data["seller"].(map[string]any) + if seller["user_id"] != "42" || seller["display_user_id"] != "163006" || seller["merchant_asset_type"] != "COIN_SELLER_COIN" { + t.Fatalf("self salary seller search response mismatch: %+v", response) + } +} + +func TestSalaryWalletTransferMapsEachIdentityToSalaryAssetAndAllowsSelfSeller(t *testing.T) { + tests := []struct { + name string + identity string + assetType string + configure func(*fakeUserHostClient) + assertCalls func(*testing.T, *fakeUserHostClient) + }{ + { + name: "host", + identity: "HOST", + assetType: "HOST_SALARY_USD", + configure: func(hostClient *fakeUserHostClient) { + hostClient.hostProfile = &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30} + }, + assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { + t.Helper() + if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 { + t.Fatalf("host identity request mismatch: %+v", hostClient.lastHost) + } + }, + }, + { + name: "agency", + identity: "AGENCY", + assetType: "AGENCY_SALARY_USD", + configure: func(hostClient *fakeUserHostClient) { + hostClient.roleSummary = &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001} + hostClient.getAgencyResp = &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active", RegionId: 30}} + }, + assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { + t.Helper() + if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 { + t.Fatalf("agency identity requests mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency) + } + }, + }, + { + name: "bd", + identity: "BD", + assetType: "BD_SALARY_USD", + configure: func(hostClient *fakeUserHostClient) { + hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 30} + }, + assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { + t.Helper() + if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" { + t.Fatalf("bd identity request mismatch: %+v", hostClient.lastBD) + } + }, + }, + { + name: "bd-leader-admin", + identity: "BD_LEADER", + assetType: "ADMIN_SALARY_USD", + configure: func(hostClient *fakeUserHostClient) { + hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30} + }, + assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) { + t.Helper() + if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd_leader" { + t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + walletClient := &fakeWalletClient{transferSalaryResp: &walletv1.TransferSalaryToCoinSellerResponse{ + TransactionId: "wtx-salary-" + test.name, + SourceSalaryBalanceAfter: 950, + SellerBalanceAfter: 40500, + SalaryUsdMinor: 50, + CoinAmount: 40500, + CoinPerUsd: 81000, + RateMinUsdMinor: 10, + RateMaxUsdMinor: 10000, + }} + hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{ + { + UserId: 42, + DisplayUserId: "163006", + Username: "self seller", + RegionId: 30, + Status: "active", + MerchantAssetType: "COIN_SELLER_COIN", + }, + }}} + test.configure(hostClient) + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetWalletClient(walletClient) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + body := []byte(`{"command_id":"cmd-salary-self-` + test.name + `","identity":"` + test.identity + `","target_display_user_id":"163006","amount_usd":"0.50","reason":"salary transfer"}`) + request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/transfer-to-coin-seller", bytes.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-salary-transfer-self-"+test.name) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + test.assertCalls(t, hostClient) + if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 { + t.Fatalf("coin seller search request mismatch: %+v", hostClient.lastListCoinSellers) + } + if walletClient.lastTransferSalary == nil || + walletClient.lastTransferSalary.GetSourceUserId() != 42 || + walletClient.lastTransferSalary.GetSellerUserId() != 42 || + walletClient.lastTransferSalary.GetSalaryAssetType() != test.assetType || + walletClient.lastTransferSalary.GetSalaryUsdMinor() != 50 || + walletClient.lastTransferSalary.GetRegionId() != 30 { + t.Fatalf("salary transfer wallet command mismatch: %+v", walletClient.lastTransferSalary) + } + }) + } +} + +func TestSalaryWalletHistoryMapsIdentityToSalaryAsset(t *testing.T) { + walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{ + Total: 2, + Transactions: []*walletv1.WalletTransaction{ + { + EntryId: 31, + TransactionId: "wtx-admin-salary-in", + BizType: "host_salary_settlement", + AssetType: "ADMIN_SALARY_USD", + AvailableDelta: 1100, + AvailableAfter: 1100, + CreatedAtMs: 1_781_000_000_000, + }, + { + EntryId: 32, + TransactionId: "wtx-admin-salary-out", + BizType: "salary_transfer_to_coin_seller", + AssetType: "ADMIN_SALARY_USD", + AvailableDelta: -500, + AvailableAfter: 600, + CounterpartyUserId: 9001, + CreatedAtMs: 1_781_000_060_000, + }, + }, + }} + hostClient := &fakeUserHostClient{ + bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30}, + } + profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ + 9001: { + UserId: 9001, + DisplayUserId: "163001", + Username: "Seller One", + Avatar: "https://cdn.example/seller.png", + AppCode: "lalu", + }, + }} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient) + handler.SetWalletClient(walletClient) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/history?identity=BD_LEADER&page=2&page_size=10", nil) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-salary-history") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastBD.GetUserId() != 42 { + t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD) + } + if walletClient.lastTransactions == nil || + walletClient.lastTransactions.GetUserId() != 42 || + walletClient.lastTransactions.GetAssetType() != "ADMIN_SALARY_USD" || + walletClient.lastTransactions.GetPage() != 2 || + walletClient.lastTransactions.GetPageSize() != 10 { + t.Fatalf("salary wallet history request mismatch: %+v", walletClient.lastTransactions) + } + if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 { + t.Fatalf("salary history counterparty profile request mismatch: %+v", profileClient.lastBatch) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + items, itemsOK := data["items"].([]any) + if response.Code != httpkit.CodeOK || !ok || !itemsOK || data["identity"] != "BD_LEADER" || data["salary_asset_type"] != "ADMIN_SALARY_USD" || len(items) != 2 { + t.Fatalf("salary wallet history response mismatch: %+v", response) + } + second := items[1].(map[string]any) + if second["available_delta"] != float64(-500) || second["available_after"] != float64(600) || second["counterparty_display_user_id"] != "163001" { + t.Fatalf("salary wallet history item mismatch: %+v", second) + } +} + func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) { hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{ ApplicationId: 8001, diff --git a/services/gateway-service/internal/transport/http/walletapi/handler.go b/services/gateway-service/internal/transport/http/walletapi/handler.go index 31ed3f49..ae76d876 100644 --- a/services/gateway-service/internal/transport/http/walletapi/handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/handler.go @@ -59,6 +59,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers { ListCoinSellers: h.listCoinSellers, TransferCoinFromSeller: h.transferCoinFromSeller, GetSalaryWalletOverview: h.getSalaryWalletOverview, + GetSalaryWalletHistory: h.getSalaryWalletHistory, SearchSalaryWalletSeller: h.searchSalaryWalletSeller, ExchangeSalaryToCoin: h.exchangeSalaryToCoin, TransferSalaryToSeller: h.transferSalaryToCoinSeller, diff --git a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go index 1bc1d2ce..02b4942c 100644 --- a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go @@ -169,6 +169,58 @@ func (h *Handler) getSalaryWalletOverview(writer http.ResponseWriter, request *h }) } +func (h *Handler) getSalaryWalletHistory(writer http.ResponseWriter, request *http.Request) { + if h.walletClient == nil || h.userHostClient == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + identity, ok := h.resolveSalaryWalletIdentity(writer, request, request.URL.Query().Get("identity")) + if !ok { + return + } + page, ok := httpkit.PositiveInt32Query(request, "page", 1) + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + // H5 工资历史不能让前端自己传 asset_type;先用身份权限解析出对应工资钱包资产, + // 再查询当前用户的该资产流水,确保主播/Agency/BD/Admin 只看到自己身份的钱包收支。 + resp, err := h.walletClient.ListWalletTransactions(request.Context(), &walletv1.ListWalletTransactionsRequest{ + RequestId: httpkit.RequestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + UserId: identity.UserID, + AssetType: identity.SalaryAssetType, + Page: page, + PageSize: pageSize, + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + profiles, err := h.walletTransactionCounterpartyProfiles(request, resp.GetTransactions()) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + items := make([]walletTransactionData, 0, len(resp.GetTransactions())) + for _, item := range resp.GetTransactions() { + items = append(items, walletTransactionFromProto(item, profiles)) + } + httpkit.WriteOK(writer, request, map[string]any{ + "identity": identity.Identity, + "salary_asset_type": identity.SalaryAssetType, + "items": items, + "total": resp.GetTotal(), + "page": page, + "page_size": pageSize, + }) +} + func (h *Handler) searchSalaryWalletSeller(writer http.ResponseWriter, request *http.Request) { if h.userHostClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") @@ -423,6 +475,8 @@ func normalizeSalaryWalletIdentity(value string) string { switch value { case salaryIdentityHost, salaryIdentityAgency, salaryIdentityBD, salaryIdentityBDLeader: return value + case "ADMIN": + return salaryIdentityBDLeader default: return "" } diff --git a/services/wallet-service/internal/service/wallet/service.go b/services/wallet-service/internal/service/wallet/service.go index 695b519e..289678e6 100644 --- a/services/wallet-service/internal/service/wallet/service.go +++ b/services/wallet-service/internal/service/wallet/service.go @@ -461,9 +461,6 @@ func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 { return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete") } - if command.SourceUserID == command.SellerUserID { - return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "source and seller must be different") - } if command.RegionID <= 0 { return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required") } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 4fe21480..132ad8a8 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -1975,6 +1975,8 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) repository.SetAssetBalance(35001, ledger.AssetBDSalaryUSD, 10000) + repository.SetAssetBalance(35001, ledger.AssetCoin, 1234) + repository.SetAssetBalance(35001, ledger.AssetCoinSellerCoin, 0) repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7001, 1100, 5000, 92000) command := ledger.SalaryTransferToCoinSellerCommand{ @@ -2001,9 +2003,17 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) { assertBalance(t, svc, 35001, ledger.AssetBDSalaryUSD, 6000) assertBalance(t, svc, 35002, ledger.AssetCoinSellerCoin, 3680000) + assertBalance(t, svc, 35001, ledger.AssetCoin, 1234) + assertBalance(t, svc, 35001, ledger.AssetCoinSellerCoin, 0) if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { t.Fatalf("idempotent salary transfer should write one transaction, got %d", got) } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35001), ledger.AssetCoinSellerCoin, int64(3680000)); got != 0 { + t.Fatalf("salary transfer must not credit sender coin seller wallet, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35002), ledger.AssetCoinSellerCoin, int64(3680000)); got != 1 { + t.Fatalf("salary transfer must credit target coin seller wallet once, got %d", got) + } if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 { t.Fatalf("salary transfer should write two entries, got %d", got) } @@ -2012,6 +2022,108 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) { } } +// TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时, +// 只扣主播工资并给目标币商的币商专用金币入账,主播自己的普通金币和币商金币都不能增加。 +func TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + repository.SetAssetBalance(35011, ledger.AssetHostSalaryUSD, 10000) + repository.SetAssetBalance(35011, ledger.AssetCoin, 1234) + repository.SetAssetBalance(35011, ledger.AssetCoinSellerCoin, 0) + repository.SetAssetBalance(35012, ledger.AssetCoinSellerCoin, 1000) + repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7002, 10, 10000, 81000) + + receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{ + AppCode: appcode.Default, + CommandID: "cmd-host-salary-transfer-target-seller", + SourceUserID: 35011, + SellerUserID: 35012, + SalaryAssetType: ledger.AssetHostSalaryUSD, + SalaryUSDMinor: 50, + RegionID: 7002, + Reason: "host salary transfer to seller", + }) + if err != nil { + t.Fatalf("TransferSalaryToCoinSeller host to target seller failed: %v", err) + } + if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 41500 || receipt.CoinAmount != 40500 { + t.Fatalf("host salary transfer receipt mismatch: %+v", receipt) + } + + assertBalance(t, svc, 35011, ledger.AssetHostSalaryUSD, 9950) + assertBalance(t, svc, 35011, ledger.AssetCoin, 1234) + assertBalance(t, svc, 35011, ledger.AssetCoinSellerCoin, 0) + assertBalance(t, svc, 35012, ledger.AssetCoinSellerCoin, 41500) + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoin, int64(40500)); got != 0 { + t.Fatalf("host salary transfer must not credit host player coin wallet, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoinSellerCoin, int64(40500)); got != 0 { + t.Fatalf("host salary transfer must not credit host coin seller wallet, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35012), ledger.AssetCoinSellerCoin, int64(40500)); got != 1 { + t.Fatalf("host salary transfer must credit target seller coin seller wallet once, got %d", got) + } +} + +// TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets 验证同一用户同时具备主播/代理/BD/Admin 和币商身份时, +// 工资转币商允许 source_user_id == seller_user_id;账务只扣对应身份工资钱包并增加币商专用金币,不增加玩家普通金币。 +func TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets(t *testing.T) { + tests := []struct { + name string + assetType string + userID int64 + regionID int64 + }{ + {name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 35101, regionID: 7101}, + {name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 35102, regionID: 7102}, + {name: "bd", assetType: ledger.AssetBDSalaryUSD, userID: 35103, regionID: 7103}, + {name: "admin", assetType: ledger.AssetAdminSalaryUSD, userID: 35104, regionID: 7104}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + repository.SetAssetBalance(test.userID, test.assetType, 10000) + repository.SetAssetBalance(test.userID, ledger.AssetCoin, 1234) + repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, test.regionID, 10, 10000, 81000) + + receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{ + AppCode: appcode.Default, + CommandID: "cmd-salary-self-" + test.name, + SourceUserID: test.userID, + SellerUserID: test.userID, + SalaryAssetType: test.assetType, + SalaryUSDMinor: 50, + RegionID: test.regionID, + Reason: "salary transfer to own coin seller wallet", + }) + if err != nil { + t.Fatalf("TransferSalaryToCoinSeller self %s failed: %v", test.name, err) + } + if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 40500 || receipt.CoinAmount != 40500 || receipt.CoinPerUSD != 81000 || receipt.RateMinUSDMinor != 10 || receipt.RateMaxUSDMinor != 10000 { + t.Fatalf("self salary transfer receipt mismatch: %+v", receipt) + } + + assertBalance(t, svc, test.userID, test.assetType, 9950) + assertBalance(t, svc, test.userID, ledger.AssetCoinSellerCoin, 40500) + assertBalance(t, svc, test.userID, ledger.AssetCoin, 1234) + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, test.assetType, int64(-50)); got != 1 { + t.Fatalf("self transfer must debit salary asset once, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, ledger.AssetCoinSellerCoin, int64(40500)); got != 1 { + t.Fatalf("self transfer must credit coin seller asset once, got %d", got) + } + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, test.userID, ledger.AssetCoin); got != 0 { + t.Fatalf("self transfer must not credit player coin wallet, got %d", got) + } + if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", receipt.TransactionID); got != 0 { + t.Fatalf("salary transfer must not write player recharge record, got %d", got) + } + }) + } +} + // TestTransferSalaryToCoinSellerRequiresConfiguredRate 验证当前区域没有可命中的工资转币商比例时不会产生半成品账务。 func TestTransferSalaryToCoinSellerRequiresConfiguredRate(t *testing.T) { repository := mysqltest.NewRepository(t)