diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index 1eab22c0..3c1d954f 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -70,6 +70,8 @@ var catalog = map[Code]Spec{ DeviceAlreadyRegistered, "device already registered", ), + CPAlreadyExistsSelf: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsSelf, "You already have a CP"), + CPAlreadyExistsOther: spec(codes.FailedPrecondition, httpStatusConflict, CPAlreadyExistsOther, "The other party already has a CP"), InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"), DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "billing command already processed"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index f761474e..91d92bcc 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -75,6 +75,10 @@ const ( InvalidInviteCode Code = "INVALID_INVITE_CODE" // DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。 DeviceAlreadyRegistered Code = "DEVICE_ALREADY_REGISTERED" + // CPAlreadyExistsSelf 表示当前点击同意申请的用户已经有 active CP。 + CPAlreadyExistsSelf Code = "CP_ALREADY_EXISTS_SELF" + // CPAlreadyExistsOther 表示申请另一方已经有 active CP。 + CPAlreadyExistsOther Code = "CP_ALREADY_EXISTS_OTHER" // InsufficientBalance 表示钱包余额不足。 InsufficientBalance Code = "INSUFFICIENT_BALANCE" diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index 59e4004e..a569bfa5 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -119,6 +119,22 @@ func TestCatalogMappings(t *testing.T) { publicCode: string(DeviceAlreadyRegistered), publicMessage: "device already registered", }, + { + name: "cp conflict identifies current user", + code: CPAlreadyExistsSelf, + grpcCode: codes.FailedPrecondition, + httpStatus: httpStatusConflict, + publicCode: string(CPAlreadyExistsSelf), + publicMessage: "You already have a CP", + }, + { + name: "cp conflict identifies other party", + code: CPAlreadyExistsOther, + grpcCode: codes.FailedPrecondition, + httpStatus: httpStatusConflict, + publicCode: string(CPAlreadyExistsOther), + publicMessage: "The other party already has a CP", + }, { name: "region mismatch keeps forbidden transport but exposes concrete app prompt", code: RegionMismatch, diff --git a/services/user-service/internal/storage/mysql/cp/repository.go b/services/user-service/internal/storage/mysql/cp/repository.go index e16f5b1f..b0f34508 100644 --- a/services/user-service/internal/storage/mysql/cp/repository.go +++ b/services/user-service/internal/storage/mysql/cp/repository.go @@ -121,7 +121,7 @@ func (r *Repository) AcceptApplication(ctx context.Context, userID int64, applic return cpdomain.Application{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("%s用户和你已经有%s关系", application.Requester.Username, existing.RelationType)) } // 容量校验按关系类型读取后台配置;CP 固定只能有一个,兄弟/姐妹允许按运营配置扩展。 - if err := assertRelationCapacityTx(ctx, tx, appCode, application.RelationType, application.Requester.UserID, application.Target.UserID); err != nil { + if err := assertRelationCapacityTx(ctx, tx, appCode, application.RelationType, application.Requester.UserID, application.Target.UserID, userID); err != nil { return cpdomain.Application{}, cpdomain.Relationship{}, err } @@ -1017,7 +1017,7 @@ func ensurePairHasNoActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCod return nil } -func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64) error { +func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, relationType string, userAID int64, userBID int64, actorUserID int64) error { if normalizeRelationType(relationType) == "" { return xerr.New(xerr.Conflict, "cp relation type is disabled") } @@ -1039,6 +1039,14 @@ func assertRelationCapacityTx(ctx context.Context, tx *sql.Tx, appCode string, r return err } if total >= int64(maxCount) { + // CP 固定一人一个;同意按钮的操作者需要知道是自己还是申请另一方已占用名额。 + // 兄弟/姐妹由后台配置多名额,继续使用通用容量错误,避免把非 CP 关系误称为 CP。 + if normalizeRelationType(relationType) == cpdomain.RelationTypeCP { + if userID == actorUserID { + return xerr.New(xerr.CPAlreadyExistsSelf, "current user already has an active cp relationship") + } + return xerr.New(xerr.CPAlreadyExistsOther, "other party already has an active cp relationship") + } return xerr.New(xerr.Conflict, "user cp relation count reaches limit") } } diff --git a/services/user-service/internal/storage/mysql/cp/repository_test.go b/services/user-service/internal/storage/mysql/cp/repository_test.go index ec33a254..4d2ad9ac 100644 --- a/services/user-service/internal/storage/mysql/cp/repository_test.go +++ b/services/user-service/internal/storage/mysql/cp/repository_test.go @@ -93,24 +93,28 @@ func TestAcceptApplicationUsesSiblingCapacityConfig(t *testing.T) { } } -func TestAcceptApplicationKeepsCPHardLimit(t *testing.T) { +func TestAcceptApplicationKeepsCPHardLimitAndIdentifiesOccupiedParty(t *testing.T) { schema := mysqlschema.New(t) repo := New(schema.DB) ctx := appcode.WithContext(context.Background(), appcode.Default) nowMS := int64(1700000000000) - for _, userID := range []int64{3001, 3002, 3003} { + for _, userID := range []int64{3001, 3002, 3003, 3004} { seedCPTestUser(t, schema.DB, userID, fmt.Sprintf("CP %d", userID), nowMS) } seedRelationConfig(t, schema.DB, cpdomain.RelationTypeCP, 9, nowMS) seedPendingCPApplication(t, schema.DB, "cp_1", cpdomain.RelationTypeCP, 3001, 3002, nowMS) - seedPendingCPApplication(t, schema.DB, "cp_2", cpdomain.RelationTypeCP, 3001, 3003, nowMS) + seedPendingCPApplication(t, schema.DB, "cp_other_occupied", cpdomain.RelationTypeCP, 3001, 3003, nowMS) + seedPendingCPApplication(t, schema.DB, "cp_self_occupied", cpdomain.RelationTypeCP, 3004, 3002, nowMS) if _, _, err := repo.AcceptApplication(ctx, 3002, "cp_1", nowMS); err != nil { t.Fatalf("accept first cp relationship failed: %v", err) } - if _, _, err := repo.AcceptApplication(ctx, 3003, "cp_2", nowMS+1); xerr.CodeOf(err) != xerr.Conflict { - t.Fatalf("second cp relationship must ignore expanded config and stay limited, got %v", err) + if _, _, err := repo.AcceptApplication(ctx, 3003, "cp_other_occupied", nowMS+1); xerr.CodeOf(err) != xerr.CPAlreadyExistsOther { + t.Fatalf("occupied requester must return other-party cp error, got %v", err) + } + if _, _, err := repo.AcceptApplication(ctx, 3002, "cp_self_occupied", nowMS+2); xerr.CodeOf(err) != xerr.CPAlreadyExistsSelf { + t.Fatalf("occupied actor must return self cp error, got %v", err) } } diff --git a/services/wallet-service/internal/client/binance/client.go b/services/wallet-service/internal/client/binance/client.go index bf671eab..9631be8a 100644 --- a/services/wallet-service/internal/client/binance/client.go +++ b/services/wallet-service/internal/client/binance/client.go @@ -21,6 +21,10 @@ import ( const ( defaultDepositLookback = 90 * 24 * time.Hour depositHistoryLimit = 1000 + defaultPayLookback = 90 * 24 * time.Hour + payOrderTimeRadius = time.Hour + payHistoryLimit = 100 + binancePaySuccessCode = "000000" ) // Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。 @@ -61,32 +65,46 @@ func newAccount(cfg config.BinanceAccountConfig) account { } } -// FindUSDTDeposit 查询 Binance deposit history,并按后台订单输入校验币种、网络、状态、地址和金额。 -func (c *Client) FindUSDTDeposit(ctx context.Context, req ports.BinanceDepositLookupRequest) (ports.BinanceDepositRecord, error) { +// FindUSDTTransfer 先查链上 Deposit History,再在未命中时查 Funding Account 的 Pay C2C History。 +// 两个事实源的成功语义不同:链上入金必须校验网络、地址和状态;Pay C2C 必须校验正金额,证明是当前 API Key 账户收款而不是付款。 +func (c *Client) FindUSDTTransfer(ctx context.Context, req ports.BinanceTransferLookupRequest) (ports.BinanceTransferRecord, error) { if c == nil || c.apiBaseURL == "" { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured") } account, ok := c.accountForApp(req.AppCode) if !ok { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance account is not configured") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Unavailable, "binance account is not configured") } req.ExternalID = strings.TrimSpace(req.ExternalID) req.Coin = strings.ToUpper(strings.TrimSpace(req.Coin)) req.Network = strings.ToUpper(strings.TrimSpace(req.Network)) req.ToAddress = strings.TrimSpace(req.ToAddress) if req.ExternalID == "" || req.Coin == "" || req.Network == "" || req.ToAddress == "" || req.AmountMinor <= 0 { - return ports.BinanceDepositRecord{}, xerr.New(xerr.InvalidArgument, "binance deposit verification request is incomplete") + return ports.BinanceTransferRecord{}, xerr.New(xerr.InvalidArgument, "binance transfer verification request is incomplete") } serverTimeMS, err := c.serverTime(ctx) if err != nil { - return ports.BinanceDepositRecord{}, err + return ports.BinanceTransferRecord{}, err } // Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。 records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS) if err != nil { - return ports.BinanceDepositRecord{}, err + return ports.BinanceTransferRecord{}, err } - return matchDepositRecord(req, records) + matched, err := matchDepositRecord(req, records) + if err == nil { + return matched, nil + } + if !xerr.IsCode(err, xerr.NotFound) { + // 同一编号已经命中链上事实但字段冲突时禁止切换事实源,避免用 Pay 记录绕过地址、网络或状态校验。 + return ports.BinanceTransferRecord{}, err + } + startTimeMS, endTimeMS := payHistoryRange(req.OrderTimeMS, serverTimeMS) + payRecords, err := c.payHistory(ctx, account, startTimeMS, endTimeMS, serverTimeMS) + if err != nil { + return ports.BinanceTransferRecord{}, err + } + return matchPayRecord(req, payRecords) } func (c *Client) accountForApp(appCode string) (account, bool) { @@ -145,6 +163,53 @@ func (c *Client) depositHistory(ctx context.Context, account account, coin strin return records, nil } +// payHistory 查询 Binance Pay 用户账单。接口单次最多返回 100 条,调用方提供订单时间时使用窄窗口降低高频账户漏单概率。 +func (c *Client) payHistory(ctx context.Context, account account, startTimeMS int64, endTimeMS int64, serverTimeMS int64) ([]payRecord, error) { + query := url.Values{} + query.Set("startTime", strconv.FormatInt(startTimeMS, 10)) + query.Set("endTime", strconv.FormatInt(endTimeMS, 10)) + query.Set("limit", strconv.Itoa(payHistoryLimit)) + // 查询区间可以是历史日期,但 signed API 的 timestamp 必须始终使用当前 Binance server time,否则旧订单会被 -1021 拒绝。 + body, err := c.signedGET(ctx, account, "/sapi/v1/pay/transactions", query, serverTimeMS) + if err != nil { + return nil, err + } + var response payHistoryResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + if strings.TrimSpace(response.Code) != binancePaySuccessCode || !response.Success { + return nil, xerr.New(xerr.Unavailable, "binance pay history returned non-success") + } + records := make([]payRecord, 0, len(response.Data)) + for _, raw := range response.Data { + var wire payRecordWire + if err := json.Unmarshal(raw, &wire); err != nil { + return nil, err + } + record := wire.toRecord() + // Pay 回包含付款人与收款人的账户资料;持久审计只保留完成资金校验所需字段,避免把无关 PII 写入充值凭证。 + record.RawJSON = safePayRecordJSON(record) + records = append(records, record) + } + return records, nil +} + +func payHistoryRange(orderTimeMS int64, serverTimeMS int64) (int64, int64) { + lookbackMS := int64(defaultPayLookback / time.Millisecond) + if orderTimeMS <= 0 { + return maxInt64(0, serverTimeMS-lookbackMS), serverTimeMS + } + radiusMS := int64(payOrderTimeRadius / time.Millisecond) + startTimeMS := maxInt64(0, orderTimeMS-radiusMS) + endTimeMS := minInt64(serverTimeMS, orderTimeMS+radiusMS) + if endTimeMS <= startTimeMS { + // 未来订单时间不应发生;这里返回最小合法区间,让 Binance 给出空结果而不是发出反向时间范围。 + endTimeMS = startTimeMS + 1 + } + return startTimeMS, endTimeMS +} + func (c *Client) signedGET(ctx context.Context, account account, path string, values url.Values, timestampMS int64) ([]byte, error) { values.Set("recvWindow", strconv.FormatInt(c.recvWindow, 10)) values.Set("timestamp", strconv.FormatInt(timestampMS, 10)) @@ -175,7 +240,7 @@ func (c *Client) do(req *http.Request) ([]byte, error) { return body, nil } -func matchDepositRecord(req ports.BinanceDepositLookupRequest, records []depositRecord) (ports.BinanceDepositRecord, error) { +func matchDepositRecord(req ports.BinanceTransferLookupRequest, records []depositRecord) (ports.BinanceTransferRecord, error) { var candidate *depositRecord for index := range records { if depositTxIDMatches(records[index].TxID, req.ExternalID) { @@ -184,28 +249,29 @@ func matchDepositRecord(req ports.BinanceDepositLookupRequest, records []deposit } } if candidate == nil { - return ports.BinanceDepositRecord{}, xerr.New(xerr.NotFound, "binance deposit not found") + return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance deposit not found") } if !strings.EqualFold(candidate.Coin, req.Coin) { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch") } if !strings.EqualFold(candidate.Network, req.Network) { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch") } if candidate.Status != 1 { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful") } if !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch") } amountMinor, err := parseUSDTAmountMinor(candidate.Amount) if err != nil { - return ports.BinanceDepositRecord{}, err + return ports.BinanceTransferRecord{}, err } if amountMinor != req.AmountMinor { - return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch") + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch") } - return ports.BinanceDepositRecord{ + return ports.BinanceTransferRecord{ + Source: "deposit_history", ID: candidate.ID, TxID: candidate.TxID, Coin: candidate.Coin, @@ -224,6 +290,61 @@ func matchDepositRecord(req ports.BinanceDepositLookupRequest, records []deposit }, nil } +func matchPayRecord(req ports.BinanceTransferLookupRequest, records []payRecord) (ports.BinanceTransferRecord, error) { + var candidate *payRecord + for index := range records { + if payExternalIDMatches(records[index], req.ExternalID) { + candidate = &records[index] + break + } + } + if candidate == nil { + return ports.BinanceTransferRecord{}, xerr.New(xerr.NotFound, "binance pay transfer not found") + } + if !strings.EqualFold(candidate.OrderType, "C2C") { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay order type is not c2c") + } + if !strings.EqualFold(candidate.Currency, req.Coin) { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay currency mismatch") + } + // Binance Pay 官方语义中正数是当前 API Key 账户收入、负数是支出;只允许正数,防止付款截图被当成平台收款。 + if strings.HasPrefix(strings.TrimSpace(candidate.Amount), "-") { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay transfer is not income") + } + amountMinor, err := parseUSDTAmountMinor(candidate.Amount) + if err != nil { + return ports.BinanceTransferRecord{}, err + } + if amountMinor <= 0 { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay transfer is not income") + } + if amountMinor != req.AmountMinor { + return ports.BinanceTransferRecord{}, xerr.New(xerr.Conflict, "binance pay amount mismatch") + } + return ports.BinanceTransferRecord{ + Source: "pay_history", + ID: candidate.OrderID, + TxID: firstNonEmpty(candidate.TransactionID, candidate.OrderID), + Coin: candidate.Currency, + Status: 1, + Amount: candidate.Amount, + AmountMinor: amountMinor, + InsertTimeMS: candidate.TransactionTimeMS, + CompleteTimeMS: candidate.TransactionTimeMS, + WalletType: candidate.WalletType, + RawJSON: strings.TrimSpace(candidate.RawJSON), + }, nil +} + +func payExternalIDMatches(record payRecord, externalID string) bool { + externalID = strings.TrimSpace(externalID) + if externalID == "" { + return false + } + return strings.EqualFold(strings.TrimSpace(record.OrderID), externalID) || + strings.EqualFold(strings.TrimSpace(record.TransactionID), externalID) +} + func depositTxIDMatches(txID string, externalID string) bool { normalizedExternalID := normalizeOffchainExternalID(externalID) if normalizedExternalID == "" { @@ -330,6 +451,71 @@ type depositRecordWire struct { WalletType int64 `json:"walletType"` } +type payHistoryResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Success bool `json:"success"` + Data []json.RawMessage `json:"data"` +} + +type payRecord struct { + OrderType string + OrderID string + TransactionID string + TransactionTimeMS int64 + Amount string + Currency string + WalletType int64 + RawJSON string +} + +type payRecordWire struct { + OrderType string `json:"orderType"` + OrderID json.RawMessage `json:"orderId"` + TransactionID string `json:"transactionId"` + TransactionTimeMS int64 `json:"transactionTime"` + Amount string `json:"amount"` + Currency string `json:"currency"` + WalletType int64 `json:"walletType"` +} + +func (wire payRecordWire) toRecord() payRecord { + return payRecord{ + OrderType: strings.ToUpper(strings.TrimSpace(wire.OrderType)), + OrderID: rawScalarString(wire.OrderID), + TransactionID: strings.TrimSpace(wire.TransactionID), + TransactionTimeMS: wire.TransactionTimeMS, + Amount: strings.TrimSpace(wire.Amount), + Currency: strings.ToUpper(strings.TrimSpace(wire.Currency)), + WalletType: wire.WalletType, + } +} + +func safePayRecordJSON(record payRecord) string { + payload := struct { + OrderType string `json:"orderType"` + OrderID string `json:"orderId,omitempty"` + TransactionID string `json:"transactionId"` + TransactionTimeMS int64 `json:"transactionTime"` + Amount string `json:"amount"` + Currency string `json:"currency"` + WalletType int64 `json:"walletType"` + }{ + OrderType: record.OrderType, + OrderID: record.OrderID, + TransactionID: record.TransactionID, + TransactionTimeMS: record.TransactionTimeMS, + Amount: record.Amount, + Currency: record.Currency, + WalletType: record.WalletType, + } + body, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(body) +} + func (wire depositRecordWire) toRecord() depositRecord { return depositRecord{ ID: rawScalarString(wire.ID), @@ -358,3 +544,17 @@ func rawScalarString(raw json.RawMessage) string { } return strings.TrimSpace(string(raw)) } + +func minInt64(left int64, right int64) int64 { + if left < right { + return left + } + return right +} + +func maxInt64(left int64, right int64) int64 { + if left > right { + return left + } + return right +} diff --git a/services/wallet-service/internal/client/binance/client_test.go b/services/wallet-service/internal/client/binance/client_test.go index 354048e9..32efeeda 100644 --- a/services/wallet-service/internal/client/binance/client_test.go +++ b/services/wallet-service/internal/client/binance/client_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -14,7 +15,7 @@ import ( "hyapp/services/wallet-service/internal/service/wallet/ports" ) -func TestFindUSDTDepositMatchesOffchainTransferAndSignedRequest(t *testing.T) { +func TestFindUSDTTransferMatchesDepositAndSignedRequest(t *testing.T) { var sawDepositRequest bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -68,7 +69,7 @@ func TestFindUSDTDepositMatchesOffchainTransferAndSignedRequest(t *testing.T) { APISecretKey: "secret-lalu", }, }) - record, err := client.FindUSDTDeposit(context.Background(), ports.BinanceDepositLookupRequest{ + record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ AppCode: "lalu", ExternalID: "388611987194", Coin: "USDT", @@ -77,18 +78,18 @@ func TestFindUSDTDepositMatchesOffchainTransferAndSignedRequest(t *testing.T) { AmountMinor: 50000, }) if err != nil { - t.Fatalf("FindUSDTDeposit failed: %v", err) + t.Fatalf("FindUSDTTransfer failed: %v", err) } if !sawDepositRequest { t.Fatalf("deposit history endpoint was not called") } - if record.TxID != "Off-chain transfer 388611987194" || record.AmountMinor != 50000 || record.Network != "TRX" || !strings.Contains(record.RawJSON, `"txId"`) { + if record.Source != "deposit_history" || record.TxID != "Off-chain transfer 388611987194" || record.AmountMinor != 50000 || record.Network != "TRX" || !strings.Contains(record.RawJSON, `"txId"`) { t.Fatalf("deposit record mismatch: %+v", record) } } -func TestFindUSDTDepositRejectsMismatches(t *testing.T) { - baseReq := ports.BinanceDepositLookupRequest{ +func TestFindUSDTTransferRejectsDepositMismatches(t *testing.T) { + baseReq := ports.BinanceTransferLookupRequest{ AppCode: "lalu", ExternalID: "388611987194", Coin: "USDT", @@ -126,7 +127,158 @@ func TestFindUSDTDepositRejectsMismatches(t *testing.T) { } } -func TestFindUSDTDepositMissingAccountReturnsConfiguredError(t *testing.T) { +func TestFindUSDTTransferFallsBackToIncomingPayC2C(t *testing.T) { + const ( + serverTimeMS = int64(1783345000000) + orderTimeMS = int64(1779713682799) + orderID = "433527303245463552" + ) + var sawPayRequest bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/time": + _, _ = w.Write([]byte(`{"serverTime":1783345000000}`)) + case "/sapi/v1/capital/deposit/hisrec": + _, _ = w.Write([]byte(`[]`)) + case "/sapi/v1/pay/transactions": + sawPayRequest = true + if r.Header.Get("X-MBX-APIKEY") != "api-yumi" { + t.Fatalf("Pay API key mismatch") + } + values := r.URL.Query() + signature := values.Get("signature") + values.Del("signature") + if signature != signQueryString(values.Encode(), "secret-yumi") { + t.Fatalf("Pay signature mismatch: query=%s signature=%s", values.Encode(), signature) + } + if values.Get("startTime") != "1779710082799" || values.Get("endTime") != "1779717282799" || values.Get("limit") != "100" || values.Get("timestamp") != strconv.FormatInt(serverTimeMS, 10) { + t.Fatalf("Pay bounded query mismatch: %s", values.Encode()) + } + _, _ = w.Write([]byte(`{ + "code":"000000", + "message":"success", + "success":true, + "data":[{ + "orderType":"C2C", + "orderId":433527303245463552, + "transactionId":"P_A223BU5M4Y171116", + "transactionTime":1783344000000, + "amount":"100.00000000", + "currency":"USDT", + "walletType":1, + "payerInfo":{"name":"must-not-persist","binanceId":"payer-id"}, + "receiverInfo":{"name":"must-not-persist","binanceId":"receiver-id"} + }] + }`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := New(config.BinanceConfig{ + Enabled: true, + APIBaseURL: server.URL, + RecvWindow: 10000, + HTTPTimeout: time.Second, + Yumi: config.BinanceAccountConfig{ + APIKey: "api-yumi", + APISecretKey: "secret-yumi", + }, + }) + record, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ + AppCode: "yumi", + ExternalID: orderID, + Coin: "USDT", + Network: "TRX", + ToAddress: "TConfiguredChainAddress", + AmountMinor: 10000, + OrderTimeMS: orderTimeMS, + }) + if err != nil { + t.Fatalf("FindUSDTTransfer Pay fallback failed: %v", err) + } + if !sawPayRequest { + t.Fatalf("Pay history endpoint was not called after deposit miss") + } + if record.Source != "pay_history" || record.ID != orderID || record.TxID != "P_A223BU5M4Y171116" || record.AmountMinor != 10000 || record.Address != "" { + t.Fatalf("Pay transfer record mismatch: %+v", record) + } + if strings.Contains(record.RawJSON, "payerInfo") || strings.Contains(record.RawJSON, "receiverInfo") || !strings.Contains(record.RawJSON, `"orderId":"`+orderID+`"`) { + t.Fatalf("Pay audit snapshot must retain evidence without account PII: %s", record.RawJSON) + } +} + +func TestFindUSDTTransferDoesNotBypassDepositConflictWithPay(t *testing.T) { + var payCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/time": + _, _ = w.Write([]byte(`{"serverTime":1783345000000}`)) + case "/sapi/v1/capital/deposit/hisrec": + _, _ = w.Write([]byte(`[{ + "id":"deposit-conflict","amount":"100","coin":"USDT","network":"TRX","status":1, + "address":"TDifferentAddress","txId":"Off-chain transfer 433527303245463552" + }]`)) + case "/sapi/v1/pay/transactions": + payCalls++ + _, _ = w.Write([]byte(`{"code":"000000","data":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := New(config.BinanceConfig{ + APIBaseURL: server.URL, + Lalu: config.BinanceAccountConfig{APIKey: "api-lalu", APISecretKey: "secret-lalu"}, + }) + _, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ + AppCode: "lalu", ExternalID: "433527303245463552", Coin: "USDT", Network: "TRX", + ToAddress: "TExpectedAddress", AmountMinor: 10000, + }) + if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), "address mismatch") { + t.Fatalf("deposit conflict must be preserved, got %v", err) + } + if payCalls != 0 { + t.Fatalf("Pay fallback must not bypass a conflicting deposit, calls=%d", payCalls) + } +} + +func TestMatchPayRecordRejectsOutgoingAndMismatchedTransfers(t *testing.T) { + baseReq := ports.BinanceTransferLookupRequest{ + ExternalID: "433527303245463552", + Coin: "USDT", + AmountMinor: 10000, + } + baseRecord := payRecord{ + OrderType: "C2C", OrderID: "433527303245463552", TransactionID: "P_A223BU5M4Y171116", + Amount: "100", Currency: "USDT", + } + cases := []struct { + name string + mutate func(*payRecord) + message string + }{ + {name: "outgoing", mutate: func(r *payRecord) { r.Amount = "-100" }, message: "not income"}, + {name: "zero", mutate: func(r *payRecord) { r.Amount = "0" }, message: "not income"}, + {name: "order type", mutate: func(r *payRecord) { r.OrderType = "PAY" }, message: "not c2c"}, + {name: "currency", mutate: func(r *payRecord) { r.Currency = "USDC" }, message: "currency mismatch"}, + {name: "amount", mutate: func(r *payRecord) { r.Amount = "99.99" }, message: "amount mismatch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + record := baseRecord + tc.mutate(&record) + _, err := matchPayRecord(baseReq, []payRecord{record}) + if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), tc.message) { + t.Fatalf("expected conflict %q, got %v", tc.message, err) + } + }) + } +} + +func TestFindUSDTTransferMissingAccountReturnsConfiguredError(t *testing.T) { client := New(config.BinanceConfig{ Enabled: true, APIBaseURL: "https://binance.invalid", @@ -135,7 +287,7 @@ func TestFindUSDTDepositMissingAccountReturnsConfiguredError(t *testing.T) { APISecretKey: "secret-lalu", }, }) - _, err := client.FindUSDTDeposit(context.Background(), ports.BinanceDepositLookupRequest{ + _, err := client.FindUSDTTransfer(context.Background(), ports.BinanceTransferLookupRequest{ AppCode: "aslan", ExternalID: "388611987194", Coin: "USDT", diff --git a/services/wallet-service/internal/service/wallet/compat.go b/services/wallet-service/internal/service/wallet/compat.go index 0aa5ad51..2ee7c15e 100644 --- a/services/wallet-service/internal/service/wallet/compat.go +++ b/services/wallet-service/internal/service/wallet/compat.go @@ -41,5 +41,5 @@ type V5PayProductTypesRequest = ports.V5PayProductTypesRequest type V5PayProductType = ports.V5PayProductType type V5PayProductTypesResponse = ports.V5PayProductTypesResponse -type BinanceDepositLookupRequest = ports.BinanceDepositLookupRequest -type BinanceDepositRecord = ports.BinanceDepositRecord +type BinanceTransferLookupRequest = ports.BinanceTransferLookupRequest +type BinanceTransferRecord = ports.BinanceTransferRecord diff --git a/services/wallet-service/internal/service/wallet/external_recharge_order.go b/services/wallet-service/internal/service/wallet/external_recharge_order.go index bfad3c80..3a2ea089 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_order.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_order.go @@ -213,8 +213,8 @@ func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitE return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, providerOrderID, command.TxHash, rawJSON) } -// verifyH5USDTTransfer 只根据凭证形态选择事实源:64 位十六进制哈希查 TRON,其他编号查当前 App 的 Binance 入金记录。 -// Binance 路径不信任付款方截图;FindUSDTDeposit 会同时核对币种、网络、收款地址、金额和成功状态。 +// verifyH5USDTTransfer 只根据凭证形态选择事实源:64 位十六进制哈希查 TRON,其他编号查当前 App 的 Binance 收款记录。 +// Binance 路径不信任付款方截图;client 会对 Deposit History 或 Pay C2C 的收款方向、币种和金额做强校验。 func (s *Service) verifyH5USDTTransfer(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand, order ledger.ExternalRechargeOrder) (string, string, error) { if isLikelyChainTransactionHash(command.TxHash) { if s.tronUSDT == nil { @@ -226,13 +226,14 @@ func (s *Service) verifyH5USDTTransfer(ctx context.Context, command ledger.Submi if s.binance == nil { return "", "", xerr.New(xerr.Unavailable, "binance client is not configured") } - record, err := s.binance.FindUSDTDeposit(ctx, ports.BinanceDepositLookupRequest{ + record, err := s.binance.FindUSDTTransfer(ctx, ports.BinanceTransferLookupRequest{ AppCode: command.AppCode, ExternalID: command.TxHash, Coin: ledger.PaidCurrencyUSDT, Network: "TRX", ToAddress: order.ReceiveAddress, AmountMinor: order.USDMinorAmount, + OrderTimeMS: order.CreatedAtMS, }) if err != nil { return "", "", err diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go index a974e596..7819594a 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt.go @@ -364,15 +364,16 @@ func (s *Service) verifyBinanceTRXDepositReceipt(ctx context.Context, command le if s.binance == nil { return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "binance client is not configured") } - // Binance off-chain 截图在付款方展示为 withdrawal,但收款账号侧只能通过 deposit history 查到账记录。 - // 这里保持 provider 仍为 usdt_trc20,只把非链上 hash 的订单号收敛到 Binance TRX 入金校验。 - record, err := s.binance.FindUSDTDeposit(ctx, ports.BinanceDepositLookupRequest{ + // 非链上编号可能来自 Deposit History 的 off-chain transfer,也可能来自 Funding Account 的 Pay C2C。 + // Binance client 必须在收款账号侧验证正向入账;付款方截图本身不能成为放币依据。 + record, err := s.binance.FindUSDTTransfer(ctx, ports.BinanceTransferLookupRequest{ AppCode: command.AppCode, ExternalID: command.ExternalOrderNo, Coin: ledger.PaidCurrencyUSDT, Network: "TRX", ToAddress: snapshot.ReceiveAddress, AmountMinor: command.USDMinorAmount, + OrderTimeMS: command.OrderDateMS, }) if err != nil { snapshot.Status = "unverified" @@ -383,7 +384,10 @@ func (s *Service) verifyBinanceTRXDepositReceipt(ctx context.Context, command le snapshot.Status = "confirmed" snapshot.ProviderOrderID = record.TxID snapshot.ProviderAmountMinor = record.AmountMinor - snapshot.ReceiveAddress = record.Address + // Pay C2C 没有链上收款地址;只有 Deposit History 命中时才用 Binance 返回地址覆盖配置快照。 + if strings.TrimSpace(record.Address) != "" { + snapshot.ReceiveAddress = record.Address + } snapshot.RawJSON = strings.TrimSpace(record.RawJSON) return snapshot, nil } diff --git a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go index 0c323d53..1bb4c601 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go +++ b/services/wallet-service/internal/service/wallet/external_recharge_receipt_test.go @@ -530,7 +530,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20HashUsesTronGrid(t *testing.T) func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing.T) { tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")} - binance := &fakeBinanceClient{record: walletservice.BinanceDepositRecord{ + binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ TxID: "Off-chain transfer 388611987194", Coin: ledger.PaidCurrencyUSDT, Network: "TRX", @@ -555,6 +555,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. ProviderCode: ledger.PaymentProviderUSDTTRC20, ExternalOrderNo: "388611987194", USDMinorAmount: 50000, + OrderDateMS: 1783344000000, }) if err != nil { t.Fatalf("VerifyCoinSellerRechargeReceipt binance offchain failed: %v", err) @@ -562,7 +563,7 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "Off-chain transfer 388611987194" || receipt.ProviderAmountMinor != 50000 || receipt.ReceiveAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" { t.Fatalf("binance offchain receipt snapshot mismatch: %+v", receipt) } - if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "TRX" || binance.req.ToAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" || binance.req.AmountMinor != 50000 { + if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "TRX" || binance.req.ToAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" || binance.req.AmountMinor != 50000 || binance.req.OrderTimeMS != 1783344000000 { t.Fatalf("binance offchain request mismatch: calls=%d req=%+v", binance.calls, binance.req) } if tron.lastTxHash != "" { @@ -570,6 +571,46 @@ func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing. } } +func TestVerifyCoinSellerRechargeReceiptUSDTTRC20PayKeepsConfiguredAddress(t *testing.T) { + binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ + Source: "pay_history", + ID: "433527303245463552", + TxID: "P_A223BU5M4Y171116", + Coin: ledger.PaidCurrencyUSDT, + Status: 1, + Amount: "100.00000000", + AmountMinor: 10000, + RawJSON: `{"orderType":"C2C","orderId":"433527303245463552","amount":"100.00000000","currency":"USDT"}`, + }} + svc := walletservice.New(nil) + svc.SetBinanceClient(binance) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{ + USDTTRC20Enabled: true, + USDTTRC20Addresses: map[string]string{ + "yumi": "TYumiConfiguredChainAddress", + }, + }) + receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{ + AppCode: "yumi", + ProviderCode: ledger.PaymentProviderUSDTTRC20, + ExternalOrderNo: "433527303245463552", + USDMinorAmount: 10000, + OrderDateMS: 1779713682799, + }) + if err != nil { + t.Fatalf("VerifyCoinSellerRechargeReceipt Pay C2C failed: %v", err) + } + if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "P_A223BU5M4Y171116" || receipt.ProviderAmountMinor != 10000 { + t.Fatalf("Pay C2C receipt snapshot mismatch: %+v", receipt) + } + if receipt.ReceiveAddress != "TYumiConfiguredChainAddress" { + t.Fatalf("Pay C2C must not erase configured chain address: %+v", receipt) + } + if binance.req.OrderTimeMS != 1779713682799 { + t.Fatalf("coin seller order time must bound Pay History query: %+v", binance.req) + } +} + func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(t *testing.T) { binance := &fakeBinanceClient{err: xerr.New(xerr.Unavailable, "binance account is not configured")} svc := walletservice.New(nil) @@ -603,9 +644,10 @@ func TestSubmitH5RechargeTxBinanceOffchainUsesAppScopedAccount(t *testing.T) { ProviderCode: ledger.PaymentProviderUSDTTRC20, ReceiveAddress: "TReceiveAddress-" + appCode, Status: ledger.ExternalRechargeStatusPending, + CreatedAtMS: 1783344000000, } repository := &fakeH5USDTRepository{order: order} - binance := &fakeBinanceClient{record: walletservice.BinanceDepositRecord{ + binance := &fakeBinanceClient{record: walletservice.BinanceTransferRecord{ TxID: "Off-chain transfer 390597460031", AmountMinor: 30000, Address: order.ReceiveAddress, @@ -628,7 +670,7 @@ func TestSubmitH5RechargeTxBinanceOffchainUsesAppScopedAccount(t *testing.T) { if credited.Status != ledger.ExternalRechargeStatusCredited { t.Fatalf("Binance off-chain order was not credited: %+v", credited) } - if binance.calls != 1 || binance.req.AppCode != appCode || binance.req.ExternalID != "390597460031" || binance.req.Coin != ledger.PaidCurrencyUSDT || binance.req.Network != "TRX" || binance.req.ToAddress != order.ReceiveAddress || binance.req.AmountMinor != 30000 { + if binance.calls != 1 || binance.req.AppCode != appCode || binance.req.ExternalID != "390597460031" || binance.req.Coin != ledger.PaidCurrencyUSDT || binance.req.Network != "TRX" || binance.req.ToAddress != order.ReceiveAddress || binance.req.AmountMinor != 30000 || binance.req.OrderTimeMS != order.CreatedAtMS { t.Fatalf("Binance app-scoped lookup mismatch: calls=%d req=%+v", binance.calls, binance.req) } if tron.lastTxHash != "" { @@ -713,17 +755,17 @@ func sameUTCDate(left time.Time, right time.Time) bool { } type fakeBinanceClient struct { - req walletservice.BinanceDepositLookupRequest - record walletservice.BinanceDepositRecord + req walletservice.BinanceTransferLookupRequest + record walletservice.BinanceTransferRecord err error calls int } -func (f *fakeBinanceClient) FindUSDTDeposit(_ context.Context, req walletservice.BinanceDepositLookupRequest) (walletservice.BinanceDepositRecord, error) { +func (f *fakeBinanceClient) FindUSDTTransfer(_ context.Context, req walletservice.BinanceTransferLookupRequest) (walletservice.BinanceTransferRecord, error) { f.calls++ f.req = req if f.err != nil { - return walletservice.BinanceDepositRecord{}, f.err + return walletservice.BinanceTransferRecord{}, f.err } return f.record, nil } diff --git a/services/wallet-service/internal/service/wallet/ports/clients.go b/services/wallet-service/internal/service/wallet/ports/clients.go index 67b110ee..6fd3a84a 100644 --- a/services/wallet-service/internal/service/wallet/ports/clients.go +++ b/services/wallet-service/internal/service/wallet/ports/clients.go @@ -147,18 +147,22 @@ type V5PayProductTypesResponse struct { RawJSON string } -// BinanceDepositLookupRequest 是 H5 充值和后台 USDT 币商充值共用的 Binance 入金查单条件。 -type BinanceDepositLookupRequest struct { +// BinanceTransferLookupRequest 是 H5 充值和后台 USDT 币商充值共用的 Binance 收款查单条件。 +// OrderTimeMS 是运营看到的付款时间;Pay History 每次最多返回 100 条,因此必须用它缩小高频账户的检索窗口。 +type BinanceTransferLookupRequest struct { AppCode string ExternalID string Coin string Network string ToAddress string AmountMinor int64 + OrderTimeMS int64 } -// BinanceDepositRecord 是 Binance deposit history 中命中的只读快照;金额按 USD/USDT cent 收敛给上层校验。 -type BinanceDepositRecord struct { +// BinanceTransferRecord 是链上 Deposit History 或站内 Pay History 命中的只读快照。 +// Source 明确事实来源;Pay 记录没有链上网络和地址,上层不得把配置地址误写成 Binance 返回事实。 +type BinanceTransferRecord struct { + Source string ID string TxID string Coin string @@ -201,7 +205,7 @@ type BSCUSDTClient interface { VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) } -// BinanceClient 隔离 Binance signed API;service 只关心入金记录是否匹配当前 App 订单。 +// BinanceClient 隔离 Binance signed API;service 只关心收款事实是否匹配当前 App 订单。 type BinanceClient interface { - FindUSDTDeposit(ctx context.Context, req BinanceDepositLookupRequest) (BinanceDepositRecord, error) + FindUSDTTransfer(ctx context.Context, req BinanceTransferLookupRequest) (BinanceTransferRecord, error) }