c2c入账
This commit is contained in:
parent
4e0c9f7cb2
commit
6f466fa174
@ -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"),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user