c2c入账

This commit is contained in:
zhx 2026-07-14 12:01:41 +08:00
parent 4e0c9f7cb2
commit 6f466fa174
12 changed files with 492 additions and 55 deletions

View File

@ -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"),

View File

@ -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"

View File

@ -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,

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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 APIservice 只关心入金记录是否匹配当前 App 订单。
// BinanceClient 隔离 Binance signed APIservice 只关心收款事实是否匹配当前 App 订单。
type BinanceClient interface {
FindUSDTDeposit(ctx context.Context, req BinanceDepositLookupRequest) (BinanceDepositRecord, error)
FindUSDTTransfer(ctx context.Context, req BinanceTransferLookupRequest) (BinanceTransferRecord, error)
}