经理中心以及币安充值
This commit is contained in:
parent
47a7725b33
commit
171ba88a2e
@ -14,6 +14,7 @@ import (
|
|||||||
"chatapp3-golang/internal/router"
|
"chatapp3-golang/internal/router"
|
||||||
"chatapp3-golang/internal/service/apppopup"
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
@ -45,6 +46,7 @@ func main() {
|
|||||||
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
inviteService := invite.NewInviteService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
baishunService := baishun.NewBaishunService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
|
||||||
|
binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis)
|
||||||
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways)
|
||||||
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet)
|
||||||
@ -90,6 +92,7 @@ func main() {
|
|||||||
AppPopup: appPopupService,
|
AppPopup: appPopupService,
|
||||||
Invite: inviteService,
|
Invite: inviteService,
|
||||||
Baishun: baishunService,
|
Baishun: baishunService,
|
||||||
|
BinanceRecharge: binanceRechargeService,
|
||||||
Lingxian: lingxianService,
|
Lingxian: lingxianService,
|
||||||
GameOpen: gameOpenService,
|
GameOpen: gameOpenService,
|
||||||
GameProviders: gameProviders,
|
GameProviders: gameProviders,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type Config struct {
|
|||||||
Lingxian LingxianConfig
|
Lingxian LingxianConfig
|
||||||
GameOpen GameOpenConfig
|
GameOpen GameOpenConfig
|
||||||
LuckyGift LuckyGiftConfig
|
LuckyGift LuckyGiftConfig
|
||||||
|
BinanceRecharge BinanceRechargeConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTPConfig 保存 HTTP 服务运行配置。
|
// HTTPConfig 保存 HTTP 服务运行配置。
|
||||||
@ -204,6 +205,11 @@ type LuckyGiftConfig struct {
|
|||||||
Providers []LuckyGiftProviderConfig
|
Providers []LuckyGiftProviderConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BinanceRechargeConfig 保存币安自动充值验证的运行配置。
|
||||||
|
type BinanceRechargeConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftProviderConfig struct {
|
type LuckyGiftProviderConfig struct {
|
||||||
StandardID int64 `json:"standardId"`
|
StandardID int64 `json:"standardId"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@ -424,6 +430,9 @@ func Load() Config {
|
|||||||
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
RewardStreamKey: getEnvAny([]string{"CHATAPP_LUCKY_GIFT_REWARD_STREAM_KEY", "LUCKY_GIFT_REWARD_STREAM_KEY"}, "lucky-gift:reward"),
|
||||||
Providers: loadLuckyGiftConfigs(),
|
Providers: loadLuckyGiftConfigs(),
|
||||||
},
|
},
|
||||||
|
BinanceRecharge: BinanceRechargeConfig{
|
||||||
|
BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -166,6 +166,11 @@ func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest)
|
|||||||
return g.Profile.PunishAccount(ctx, req)
|
return g.Profile.PunishAccount(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnblockAccount 透传到账号解封接口。
|
||||||
|
func (g *Gateways) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||||
|
return g.Profile.UnblockAccount(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// ListCurrentWeekRoomContribution 透传到房间流水网关。
|
// ListCurrentWeekRoomContribution 透传到房间流水网关。
|
||||||
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
|
func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) {
|
||||||
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
|
return g.Room.ListCurrentWeekRoomContribution(ctx, limit)
|
||||||
@ -211,6 +216,26 @@ func (g *Gateways) PageFreightSellers(ctx context.Context, authorization string,
|
|||||||
return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *Gateways) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
return g.Freight.ExistsFreightBalance(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateways) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
return g.Freight.ExistsFreightSeller(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateways) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||||
|
return g.Freight.ChangeFreightBalance(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateways) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||||
|
return g.Freight.AddFreightAgentReview(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateways) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||||
|
return g.Order.IncreaseFreightRechargeRecord(ctx, userID, amount)
|
||||||
|
}
|
||||||
|
|
||||||
// AuthGateway 负责认证相关接口。
|
// AuthGateway 负责认证相关接口。
|
||||||
type AuthGateway struct {
|
type AuthGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -276,6 +301,10 @@ func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, us
|
|||||||
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
return g.client.GetThisMonthTotalPersonalRecharge(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *OrderGateway) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||||
|
return g.client.IncreaseFreightRechargeRecord(ctx, userID, amount)
|
||||||
|
}
|
||||||
|
|
||||||
// RewardDispatchGateway 负责活动奖励发放接口。
|
// RewardDispatchGateway 负责活动奖励发放接口。
|
||||||
type RewardDispatchGateway struct {
|
type RewardDispatchGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -376,6 +405,11 @@ func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountReq
|
|||||||
return g.client.PunishAccount(ctx, req)
|
return g.client.PunishAccount(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnblockAccount 解封用户账号。
|
||||||
|
func (g *ProfileGateway) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||||
|
return g.client.UnblockAccount(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// RoomGateway 负责房间资料和房间流水相关接口。
|
// RoomGateway 负责房间资料和房间流水相关接口。
|
||||||
type RoomGateway struct {
|
type RoomGateway struct {
|
||||||
client *Client
|
client *Client
|
||||||
@ -440,3 +474,19 @@ type FreightGateway struct {
|
|||||||
func (g *FreightGateway) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
|
func (g *FreightGateway) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) {
|
||||||
return g.client.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
return g.client.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *FreightGateway) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
return g.client.ExistsFreightBalance(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *FreightGateway) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
return g.client.ExistsFreightSeller(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *FreightGateway) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||||
|
return g.client.ChangeFreightBalance(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *FreightGateway) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||||
|
return g.client.AddFreightAgentReview(ctx, req)
|
||||||
|
}
|
||||||
|
|||||||
@ -179,6 +179,12 @@ type PunishAccountRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnblockAccountRequest struct {
|
||||||
|
BeOptUserID int64 `json:"beOptUserId"`
|
||||||
|
OptUserID int64 `json:"optUserId"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
type SendActivityRewardRequest struct {
|
type SendActivityRewardRequest struct {
|
||||||
TrackID int64 `json:"trackId"`
|
TrackID int64 `json:"trackId"`
|
||||||
Origin string `json:"origin"`
|
Origin string `json:"origin"`
|
||||||
@ -296,6 +302,25 @@ type FreightSellerProfile struct {
|
|||||||
OriginSys string `json:"originSys"`
|
OriginSys string `json:"originSys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FreightBalanceChangeRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
UserID int64 `json:"userId"`
|
||||||
|
AcceptUserID int64 `json:"acceptUserId"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AcceptAmount string `json:"acceptAmount"`
|
||||||
|
OrderAmount string `json:"orderAmount"`
|
||||||
|
USDAmount string `json:"usdAmount"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
RechargeType string `json:"rechargeType"`
|
||||||
|
CreateUser int64 `json:"createUser"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FreightAgentReviewRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
UserID int64 `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
type freightSellerPageResponse struct {
|
type freightSellerPageResponse struct {
|
||||||
Status *bool `json:"status"`
|
Status *bool `json:"status"`
|
||||||
ErrorMsg string `json:"errorMsg"`
|
ErrorMsg string `json:"errorMsg"`
|
||||||
@ -746,6 +771,94 @@ func (c *Client) PageFreightSellers(ctx context.Context, authorization string, s
|
|||||||
return resp.FreightSellerPage, nil
|
return resp.FreightSellerPage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) ExistsFreightBalance(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsBalance?" + values.Encode()
|
||||||
|
var resp resultResponse[bool]
|
||||||
|
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
return false, errors.New(strings.TrimSpace(resp.Message))
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ExistsFreightSeller(ctx context.Context, userID int64) (bool, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/existsSeller?" + values.Encode()
|
||||||
|
var resp resultResponse[bool]
|
||||||
|
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
return false, errors.New(strings.TrimSpace(resp.Message))
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ChangeFreightBalance(ctx context.Context, req FreightBalanceChangeRequest) error {
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/changeBalance"
|
||||||
|
var resp resultResponse[any]
|
||||||
|
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
message := strings.TrimSpace(resp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "freight balance change failed"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AddFreightAgentReview(ctx context.Context, req FreightAgentReviewRequest) error {
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.WalletBaseURL, "/") + "/freight-gold/client/addFreightAgentReview"
|
||||||
|
var resp resultResponse[any]
|
||||||
|
if err := c.postJSON(ctx, endpoint, req, nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
message := strings.TrimSpace(resp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "freight agent review add failed"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return fmt.Errorf("userId is required")
|
||||||
|
}
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("userId", strconv.FormatInt(userID, 10))
|
||||||
|
values.Set("amount", strings.TrimSpace(amount))
|
||||||
|
endpoint := strings.TrimRight(c.cfg.Java.OrderBaseURL, "/") + "/user/freight/recharge-record/client/inrNowMonthAmount?" + values.Encode()
|
||||||
|
var resp resultResponse[DecimalString]
|
||||||
|
if err := c.getJSON(ctx, endpoint, nil, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
message := strings.TrimSpace(resp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "freight recharge record increase failed"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
func (c *Client) GetUserProfile(ctx context.Context, userID int64) (UserProfile, error) {
|
||||||
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10))
|
||||||
var resp resultResponse[UserProfile]
|
var resp resultResponse[UserProfile]
|
||||||
@ -970,6 +1083,10 @@ func (c *Client) PunishAccount(ctx context.Context, req PunishAccountRequest) er
|
|||||||
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/punishment", req, nil, nil)
|
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/punishment", req, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error {
|
||||||
|
return c.postJSON(ctx, c.cfg.Java.OtherBaseURL+"/app-user/account/unblock", req, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
|
func (c *Client) MapGoldBalance(ctx context.Context, userIDs []int64) (map[int64]int64, error) {
|
||||||
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
|
endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance"
|
||||||
var resp resultResponse[map[string]int64]
|
var resp resultResponse[map[string]int64]
|
||||||
|
|||||||
60
internal/model/binance_recharge_models.go
Normal file
60
internal/model/binance_recharge_models.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// BinanceRechargeConfig stores Binance API credentials per app origin.
|
||||||
|
type BinanceRechargeConfig struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_binance_recharge_config_origin"`
|
||||||
|
APIKey string `gorm:"column:api_key;size:255"`
|
||||||
|
APISecret string `gorm:"column:api_secret;size:255"`
|
||||||
|
Enabled bool `gorm:"column:enabled"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BinanceRechargeConfig) TableName() string {
|
||||||
|
return "binance_recharge_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceRechargeRate maps a verified USDT amount range to gold per USD.
|
||||||
|
type BinanceRechargeRate struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
ConfigID int64 `gorm:"column:config_id;index:idx_binance_recharge_rate_config"`
|
||||||
|
MinAmount string `gorm:"column:min_amount;type:decimal(20,8)"`
|
||||||
|
MaxAmount string `gorm:"column:max_amount;type:decimal(20,8)"`
|
||||||
|
GoldPerUSD string `gorm:"column:gold_per_usd;type:decimal(20,8)"`
|
||||||
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BinanceRechargeRate) TableName() string {
|
||||||
|
return "binance_recharge_rate"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceRechargeVerifyRecord is the local idempotency ledger for auto recharge verification.
|
||||||
|
type BinanceRechargeVerifyRecord struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||||
|
SysOrigin string `gorm:"column:sys_origin;size:32;index:idx_binance_recharge_verify_origin;uniqueIndex:uk_binance_recharge_verify_order,priority:1;uniqueIndex:uk_binance_recharge_verify_bill,priority:1"`
|
||||||
|
DealerUserID int64 `gorm:"column:dealer_user_id;index:idx_binance_recharge_verify_dealer"`
|
||||||
|
TargetUserID int64 `gorm:"column:target_user_id;index:idx_binance_recharge_verify_target"`
|
||||||
|
OrderNo string `gorm:"column:order_no;size:128;uniqueIndex:uk_binance_recharge_verify_order,priority:2"`
|
||||||
|
TransferType string `gorm:"column:transfer_type;size:32;uniqueIndex:uk_binance_recharge_verify_bill,priority:2"`
|
||||||
|
BillNo string `gorm:"column:bill_no;size:160;uniqueIndex:uk_binance_recharge_verify_bill,priority:3"`
|
||||||
|
AmountUSDT string `gorm:"column:amount_usdt;type:decimal(20,8)"`
|
||||||
|
GoldAmount int64 `gorm:"column:gold_amount"`
|
||||||
|
RateGoldPerUSD string `gorm:"column:rate_gold_per_usd;type:decimal(20,8)"`
|
||||||
|
BinanceTransactionID string `gorm:"column:binance_transaction_id;size:180"`
|
||||||
|
BinanceOrderType string `gorm:"column:binance_order_type;size:64"`
|
||||||
|
BinanceRawJSON string `gorm:"column:binance_raw_json;type:text"`
|
||||||
|
Status string `gorm:"column:status;size:24;index:idx_binance_recharge_verify_status"`
|
||||||
|
FailReason string `gorm:"column:fail_reason;size:500"`
|
||||||
|
Remark string `gorm:"column:remark;size:500"`
|
||||||
|
CreateTime time.Time `gorm:"column:create_time"`
|
||||||
|
UpdateTime time.Time `gorm:"column:update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BinanceRechargeVerifyRecord) TableName() string {
|
||||||
|
return "binance_recharge_verify_record"
|
||||||
|
}
|
||||||
56
internal/router/binance_recharge_routes.go
Normal file
56
internal/router/binance_recharge_routes.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerBinanceRechargeRoutes registers H5 verification and admin config routes.
|
||||||
|
func registerBinanceRechargeRoutes(engine *gin.Engine, javaClient authGateway, service *binancerecharge.Service) {
|
||||||
|
if service == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appGroup := engine.Group("/app/h5/binance-recharge")
|
||||||
|
appGroup.Use(authMiddleware(javaClient))
|
||||||
|
appGroup.POST("/verify", func(c *gin.Context) {
|
||||||
|
var req binancerecharge.VerifyRechargeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, binancerecharge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.VerifyAndRecharge(c.Request.Context(), mustAuthUser(c), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
adminGroup := engine.Group("/payment/binance")
|
||||||
|
adminGroup.Use(consoleAuthMiddleware(javaClient))
|
||||||
|
adminGroup.GET("/config", func(c *gin.Context) {
|
||||||
|
resp, err := service.GetConfig(c.Request.Context(), c.Query("sysOrigin"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
adminGroup.POST("/config/save", func(c *gin.Context) {
|
||||||
|
var req binancerecharge.SaveConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, binancerecharge.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.SaveConfig(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -73,4 +73,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
|
|||||||
}
|
}
|
||||||
writeOK(c, resp)
|
writeOK(c, resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
group.POST("/users/unban", func(c *gin.Context) {
|
||||||
|
var req managercenter.UnbanUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := service.UnbanUser(c.Request.Context(), mustAuthUser(c), req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeOK(c, resp)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"chatapp3-golang/internal/repo"
|
"chatapp3-golang/internal/repo"
|
||||||
"chatapp3-golang/internal/service/apppopup"
|
"chatapp3-golang/internal/service/apppopup"
|
||||||
"chatapp3-golang/internal/service/baishun"
|
"chatapp3-golang/internal/service/baishun"
|
||||||
|
"chatapp3-golang/internal/service/binancerecharge"
|
||||||
"chatapp3-golang/internal/service/gameopen"
|
"chatapp3-golang/internal/service/gameopen"
|
||||||
"chatapp3-golang/internal/service/gameprovider"
|
"chatapp3-golang/internal/service/gameprovider"
|
||||||
"chatapp3-golang/internal/service/hostcenter"
|
"chatapp3-golang/internal/service/hostcenter"
|
||||||
@ -34,6 +35,7 @@ type Services struct {
|
|||||||
AppPopup *apppopup.Service
|
AppPopup *apppopup.Service
|
||||||
Invite *invite.InviteService
|
Invite *invite.InviteService
|
||||||
Baishun *baishun.BaishunService
|
Baishun *baishun.BaishunService
|
||||||
|
BinanceRecharge *binancerecharge.Service
|
||||||
Lingxian *lingxian.Service
|
Lingxian *lingxian.Service
|
||||||
GameOpen *gameopen.GameOpenService
|
GameOpen *gameopen.GameOpenService
|
||||||
GameProviders *gameprovider.Registry
|
GameProviders *gameprovider.Registry
|
||||||
@ -67,6 +69,7 @@ func NewRouter(
|
|||||||
registerInviteRoutes(engine, javaClient, services.Invite)
|
registerInviteRoutes(engine, javaClient, services.Invite)
|
||||||
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency)
|
||||||
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward)
|
||||||
|
registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge)
|
||||||
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward)
|
||||||
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
registerSignInRewardRoutes(engine, javaClient, services.SignInReward)
|
||||||
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter)
|
||||||
|
|||||||
78
internal/service/binancerecharge/amount.go
Normal file
78
internal/service/binancerecharge/amount.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parsePositiveDecimal(value string, field string) (*big.Rat, string, error) {
|
||||||
|
normalized, err := normalizeDecimal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
|
||||||
|
}
|
||||||
|
rat, ok := new(big.Rat).SetString(normalized)
|
||||||
|
if !ok || rat.Sign() <= 0 {
|
||||||
|
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than 0")
|
||||||
|
}
|
||||||
|
return rat, normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeDecimal(value string, field string) (*big.Rat, string, error) {
|
||||||
|
normalized, err := normalizeDecimal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be a decimal number")
|
||||||
|
}
|
||||||
|
rat, ok := new(big.Rat).SetString(normalized)
|
||||||
|
if !ok || rat.Sign() < 0 {
|
||||||
|
return nil, "", NewAppError(http.StatusBadRequest, "invalid_"+field, field+" must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
return rat, normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDecimal(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("blank decimal")
|
||||||
|
}
|
||||||
|
rat, ok := new(big.Rat).SetString(value)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid decimal")
|
||||||
|
}
|
||||||
|
return trimDecimalZeros(rat.FloatString(8)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDecimalZeros(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if !strings.Contains(value, ".") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
value = strings.TrimRight(value, "0")
|
||||||
|
value = strings.TrimRight(value, ".")
|
||||||
|
if value == "" || value == "-0" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalEqual(left string, right string) bool {
|
||||||
|
leftRat, ok := new(big.Rat).SetString(strings.TrimSpace(left))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rightRat, ok := new(big.Rat).SetString(strings.TrimSpace(right))
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return leftRat.Cmp(rightRat) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateGold(amount *big.Rat, rate *big.Rat) (int64, error) {
|
||||||
|
product := new(big.Rat).Mul(amount, rate)
|
||||||
|
gold := new(big.Int).Quo(product.Num(), product.Denom())
|
||||||
|
if !gold.IsInt64() {
|
||||||
|
return 0, NewAppError(http.StatusBadRequest, "gold_amount_too_large", "gold amount is too large")
|
||||||
|
}
|
||||||
|
return gold.Int64(), nil
|
||||||
|
}
|
||||||
282
internal/service/binancerecharge/binance.go
Normal file
282
internal/service/binancerecharge/binance.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBinanceBaseURL = "https://api.binance.com"
|
||||||
|
binanceUSDT = "USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPBinanceVerifier struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPBinanceVerifier(baseURL string, timeout time.Duration) *HTTPBinanceVerifier {
|
||||||
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBinanceBaseURL
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &HTTPBinanceVerifier{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *HTTPBinanceVerifier) Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||||
|
if v == nil {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
|
||||||
|
}
|
||||||
|
switch query.TransferType {
|
||||||
|
case TransferTypeBinancePay:
|
||||||
|
return v.verifyPayTransaction(ctx, credential, query)
|
||||||
|
case TransferTypeChain:
|
||||||
|
return v.verifyChainDeposit(ctx, credential, query)
|
||||||
|
default:
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *HTTPBinanceVerifier) verifyPayTransaction(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||||
|
now := time.Now()
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
|
||||||
|
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
|
||||||
|
values.Set("limit", "100")
|
||||||
|
values.Set("recvWindow", "5000")
|
||||||
|
|
||||||
|
raw, err := v.signedGet(ctx, credential, "/sapi/v1/pay/transactions", values)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedTransaction{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data []json.RawMessage `json:"data"`
|
||||||
|
Success *bool `json:"success"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
|
||||||
|
}
|
||||||
|
if resp.Success != nil && !*resp.Success {
|
||||||
|
message := strings.TrimSpace(resp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = "binance pay query failed"
|
||||||
|
}
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_query_failed", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundBill := false
|
||||||
|
for _, item := range resp.Data {
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal(item, &record); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !binancePayRecordMatchesBill(record, query.BillNo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foundBill = true
|
||||||
|
if !binancePayRecordMatchesAmount(record, query.Amount) {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
|
||||||
|
}
|
||||||
|
return VerifiedTransaction{
|
||||||
|
TransactionID: firstString(record, "transactionId", "orderId", "merchantTradeNo"),
|
||||||
|
OrderType: firstString(record, "orderType"),
|
||||||
|
Amount: query.Amount,
|
||||||
|
RawJSON: string(item),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if foundBill {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance transaction amount does not match")
|
||||||
|
}
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance transaction was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *HTTPBinanceVerifier) verifyChainDeposit(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||||
|
now := time.Now()
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("coin", binanceUSDT)
|
||||||
|
values.Set("status", "1")
|
||||||
|
values.Set("txId", query.BillNo)
|
||||||
|
values.Set("startTime", fmt.Sprintf("%d", now.AddDate(0, 0, -90).UnixMilli()))
|
||||||
|
values.Set("endTime", fmt.Sprintf("%d", now.UnixMilli()))
|
||||||
|
values.Set("limit", "1000")
|
||||||
|
values.Set("recvWindow", "5000")
|
||||||
|
|
||||||
|
raw, err := v.signedGet(ctx, credential, "/sapi/v1/capital/deposit/hisrec", values)
|
||||||
|
if err != nil {
|
||||||
|
return VerifiedTransaction{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadGateway, "binance_response_invalid", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
foundBill := false
|
||||||
|
for _, item := range rows {
|
||||||
|
var record map[string]any
|
||||||
|
if err := json.Unmarshal(item, &record); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !chainDepositRecordMatchesBill(record, query.BillNo) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
foundBill = true
|
||||||
|
if !strings.EqualFold(firstString(record, "coin"), binanceUSDT) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !chainDepositStatusSuccess(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !decimalEqual(firstString(record, "amount"), query.Amount) {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
|
||||||
|
}
|
||||||
|
return VerifiedTransaction{
|
||||||
|
TransactionID: firstString(record, "txId", "id"),
|
||||||
|
OrderType: "CHAIN_DEPOSIT",
|
||||||
|
Amount: query.Amount,
|
||||||
|
RawJSON: string(item),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if foundBill {
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_amount_mismatch", "binance deposit amount does not match")
|
||||||
|
}
|
||||||
|
return VerifiedTransaction{}, NewAppError(http.StatusBadRequest, "binance_transaction_not_found", "binance deposit was not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *HTTPBinanceVerifier) signedGet(ctx context.Context, credential BinanceCredential, path string, values url.Values) ([]byte, error) {
|
||||||
|
apiKey := strings.TrimSpace(credential.APIKey)
|
||||||
|
apiSecret := strings.TrimSpace(credential.APISecret)
|
||||||
|
if apiKey == "" || apiSecret == "" {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "binance_credentials_missing", "binance api key and secret are required")
|
||||||
|
}
|
||||||
|
values.Set("timestamp", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||||
|
query := values.Encode()
|
||||||
|
signature := signBinanceQuery(apiSecret, query)
|
||||||
|
|
||||||
|
endpoint := v.baseURL + path + "?" + query + "&signature=" + url.QueryEscape(signature)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
|
||||||
|
}
|
||||||
|
req.Header.Set("X-MBX-APIKEY", apiKey)
|
||||||
|
|
||||||
|
resp, err := v.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "binance_request_failed", err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "binance_response_read_failed", err.Error())
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "binance_query_failed", strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signBinanceQuery(secret string, query string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(query))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func binancePayRecordMatchesBill(record map[string]any, billNo string) bool {
|
||||||
|
billNo = strings.TrimSpace(billNo)
|
||||||
|
for _, key := range []string{"transactionId", "orderId", "merchantTradeNo", "prepayId", "id"} {
|
||||||
|
if strings.EqualFold(firstString(record, key), billNo) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func binancePayRecordMatchesAmount(record map[string]any, expected string) bool {
|
||||||
|
if strings.EqualFold(firstString(record, "currency"), binanceUSDT) && decimalEqual(firstString(record, "amount"), expected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
details, ok := record["fundsDetail"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range details {
|
||||||
|
detail, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(firstString(detail, "currency"), binanceUSDT) && decimalEqual(firstString(detail, "amount"), expected) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainDepositRecordMatchesBill(record map[string]any, billNo string) bool {
|
||||||
|
billNo = strings.TrimSpace(billNo)
|
||||||
|
for _, key := range []string{"txId", "id"} {
|
||||||
|
if strings.EqualFold(firstString(record, key), billNo) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainDepositStatusSuccess(record map[string]any) bool {
|
||||||
|
switch value := record["status"].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(value) == 1
|
||||||
|
case int:
|
||||||
|
return value == 1
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(value) == "1"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(record map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := stringValue(record[key]); strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringValue(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case json.Number:
|
||||||
|
return typed.String()
|
||||||
|
case float64:
|
||||||
|
return trimDecimalZeros(fmt.Sprintf("%.8f", typed))
|
||||||
|
case int:
|
||||||
|
return fmt.Sprintf("%d", typed)
|
||||||
|
case int64:
|
||||||
|
return fmt.Sprintf("%d", typed)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
220
internal/service/binancerecharge/config.go
Normal file
220
internal/service/binancerecharge/config.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigResponse, error) {
|
||||||
|
sysOrigin = normalizeSysOrigin(sysOrigin)
|
||||||
|
if sysOrigin == "" {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg model.BinanceRechargeConfig
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
First(&cfg).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return &ConfigResponse{
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
Enabled: true,
|
||||||
|
Rates: []RateConfigResponse{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusInternalServerError, "config_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
rates, err := s.listRates(ctx, cfg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConfigResponse{
|
||||||
|
SysOrigin: cfg.SysOrigin,
|
||||||
|
APIKey: cfg.APIKey,
|
||||||
|
APISecretConfigured: strings.TrimSpace(cfg.APISecret) != "",
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
Rates: rates,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*ConfigResponse, error) {
|
||||||
|
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
||||||
|
if sysOrigin == "" {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := strings.TrimSpace(req.APIKey)
|
||||||
|
apiSecret := strings.TrimSpace(req.APISecret)
|
||||||
|
rates, err := validateRateRequests(req.Rates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg model.BinanceRechargeConfig
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
now := time.Now()
|
||||||
|
var existing model.BinanceRechargeConfig
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
First(&existing).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if apiSecret == "" && existing.ID > 0 {
|
||||||
|
apiSecret = existing.APISecret
|
||||||
|
}
|
||||||
|
if apiKey == "" {
|
||||||
|
return NewAppError(http.StatusBadRequest, "api_key_required", "apiKey is required")
|
||||||
|
}
|
||||||
|
if apiSecret == "" {
|
||||||
|
return NewAppError(http.StatusBadRequest, "api_secret_required", "apiSecret is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg = model.BinanceRechargeConfig{
|
||||||
|
ID: existing.ID,
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
APIKey: apiKey,
|
||||||
|
APISecret: apiSecret,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
if existing.ID > 0 {
|
||||||
|
cfg.CreateTime = existing.CreateTime
|
||||||
|
if err := tx.Model(&model.BinanceRechargeConfig{}).
|
||||||
|
Where("id = ?", existing.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"api_key": apiKey,
|
||||||
|
"api_secret": apiSecret,
|
||||||
|
"enabled": req.Enabled,
|
||||||
|
"update_time": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if err := tx.Create(&cfg).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("config_id = ?", cfg.ID).Delete(&model.BinanceRechargeRate{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([]model.BinanceRechargeRate, 0, len(rates))
|
||||||
|
for index, item := range rates {
|
||||||
|
rows = append(rows, model.BinanceRechargeRate{
|
||||||
|
ConfigID: cfg.ID,
|
||||||
|
MinAmount: item.MinAmount,
|
||||||
|
MaxAmount: item.MaxAmount,
|
||||||
|
GoldPerUSD: item.GoldPerUSD,
|
||||||
|
SortOrder: index + 1,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tx.Create(&rows).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var appErr *AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, NewAppError(http.StatusInternalServerError, "config_save_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetConfig(ctx, sysOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) listRates(ctx context.Context, configID int64) ([]RateConfigResponse, error) {
|
||||||
|
var rows []model.BinanceRechargeRate
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("config_id = ?", configID).
|
||||||
|
Order("sort_order asc, min_amount asc, id asc").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, NewAppError(http.StatusInternalServerError, "rate_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
resp := make([]RateConfigResponse, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
resp = append(resp, RateConfigResponse{
|
||||||
|
ID: row.ID,
|
||||||
|
MinAmount: trimDecimalZeros(row.MinAmount),
|
||||||
|
MaxAmount: trimDecimalZeros(row.MaxAmount),
|
||||||
|
GoldPerUSD: trimDecimalZeros(row.GoldPerUSD),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRateRequests(input []RateConfigRequest) ([]RateConfigResponse, error) {
|
||||||
|
if len(input) == 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "rates_required", "at least one rate range is required")
|
||||||
|
}
|
||||||
|
rates := make([]RateConfigResponse, 0, len(input))
|
||||||
|
for _, item := range input {
|
||||||
|
minRat, minAmount, err := parseNonNegativeDecimal(item.MinAmount, "minAmount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
maxRat, maxAmount, err := parseNonNegativeDecimal(defaultZero(item.MaxAmount), "maxAmount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, goldPerUSD, err := parsePositiveDecimal(item.GoldPerUSD, "goldPerUsd")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if maxRat.Sign() > 0 && maxRat.Cmp(minRat) <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "maxAmount must be greater than minAmount")
|
||||||
|
}
|
||||||
|
rates = append(rates, RateConfigResponse{
|
||||||
|
MinAmount: minAmount,
|
||||||
|
MaxAmount: maxAmount,
|
||||||
|
GoldPerUSD: goldPerUSD,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(rates, func(i, j int) bool {
|
||||||
|
left, _ := new(big.Rat).SetString(rates[i].MinAmount)
|
||||||
|
right, _ := new(big.Rat).SetString(rates[j].MinAmount)
|
||||||
|
return left.Cmp(right) < 0
|
||||||
|
})
|
||||||
|
for i := 0; i < len(rates); i++ {
|
||||||
|
currentMax, _ := new(big.Rat).SetString(rates[i].MaxAmount)
|
||||||
|
if currentMax.Sign() == 0 {
|
||||||
|
if i != len(rates)-1 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "open-ended rate range must be the last range")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 < len(rates) {
|
||||||
|
nextMin, _ := new(big.Rat).SetString(rates[i+1].MinAmount)
|
||||||
|
if currentMax.Cmp(nextMin) > 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "invalid_rate_range", "rate ranges cannot overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultZero(value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
201
internal/service/binancerecharge/service_test.go
Normal file
201
internal/service/binancerecharge/service_test.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveConfigKeepsExistingSecretWhenBlank(t *testing.T) {
|
||||||
|
db := newBinanceRechargeTestDB(t)
|
||||||
|
service := NewService(config.Config{}, db, &fakeBinanceJavaGateway{})
|
||||||
|
|
||||||
|
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
APIKey: "key-1",
|
||||||
|
APISecret: "secret-1",
|
||||||
|
Enabled: true,
|
||||||
|
Rates: []RateConfigRequest{
|
||||||
|
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("initial save config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
APIKey: "key-2",
|
||||||
|
Enabled: true,
|
||||||
|
Rates: []RateConfigRequest{
|
||||||
|
{MinAmount: "0", MaxAmount: "0", GoldPerUSD: "82000"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
|
}
|
||||||
|
if resp.APIKey != "key-2" {
|
||||||
|
t.Fatalf("APIKey = %q, want key-2", resp.APIKey)
|
||||||
|
}
|
||||||
|
if !resp.APISecretConfigured {
|
||||||
|
t.Fatalf("APISecretConfigured = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row model.BinanceRechargeConfig
|
||||||
|
if err := db.Where("sys_origin = ?", "LIKEI").First(&row).Error; err != nil {
|
||||||
|
t.Fatalf("query config: %v", err)
|
||||||
|
}
|
||||||
|
if row.APISecret != "secret-1" {
|
||||||
|
t.Fatalf("APISecret = %q, want secret-1", row.APISecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyAndRechargeIsIdempotent(t *testing.T) {
|
||||||
|
db := newBinanceRechargeTestDB(t)
|
||||||
|
java := &fakeBinanceJavaGateway{
|
||||||
|
profile: integration.UserProfile{
|
||||||
|
ID: integration.Int64Value(200),
|
||||||
|
OriginSys: "LIKEI",
|
||||||
|
},
|
||||||
|
balanceExists: true,
|
||||||
|
}
|
||||||
|
service := NewService(config.Config{}, db, java)
|
||||||
|
service.SetBinanceVerifier(&fakeBinanceVerifier{})
|
||||||
|
|
||||||
|
if _, err := service.SaveConfig(context.Background(), SaveConfigRequest{
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
APIKey: "key",
|
||||||
|
APISecret: "secret",
|
||||||
|
Enabled: true,
|
||||||
|
Rates: []RateConfigRequest{
|
||||||
|
{MinAmount: "0", MaxAmount: "100", GoldPerUSD: "80000"},
|
||||||
|
{MinAmount: "100", MaxAmount: "0", GoldPerUSD: "82000"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := VerifyRechargeRequest{
|
||||||
|
TargetUserID: 200,
|
||||||
|
OrderNo: "order-1",
|
||||||
|
TransferAmount: "12.5",
|
||||||
|
TransferType: TransferTypeBinancePay,
|
||||||
|
BillNo: "bill-1",
|
||||||
|
}
|
||||||
|
resp, err := service.VerifyAndRecharge(context.Background(), AuthUser{
|
||||||
|
UserID: 100,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify recharge: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Idempotent {
|
||||||
|
t.Fatalf("first response idempotent = true, want false")
|
||||||
|
}
|
||||||
|
if resp.GoldAmount != 1_000_000 {
|
||||||
|
t.Fatalf("GoldAmount = %d, want 1000000", resp.GoldAmount)
|
||||||
|
}
|
||||||
|
if java.changeCalls != 1 {
|
||||||
|
t.Fatalf("changeCalls = %d, want 1", java.changeCalls)
|
||||||
|
}
|
||||||
|
if java.lastChange.RechargeType != rechargeTypeUSDT {
|
||||||
|
t.Fatalf("RechargeType = %q, want %q", java.lastChange.RechargeType, rechargeTypeUSDT)
|
||||||
|
}
|
||||||
|
if java.lastChange.AcceptAmount != "1000000" {
|
||||||
|
t.Fatalf("AcceptAmount = %q, want 1000000", java.lastChange.AcceptAmount)
|
||||||
|
}
|
||||||
|
if java.lastChange.OrderAmount != "12.5" {
|
||||||
|
t.Fatalf("OrderAmount = %q, want 12.5", java.lastChange.OrderAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = service.VerifyAndRecharge(context.Background(), AuthUser{
|
||||||
|
UserID: 100,
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
}, req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify idempotent recharge: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Idempotent {
|
||||||
|
t.Fatalf("second response idempotent = false, want true")
|
||||||
|
}
|
||||||
|
if java.changeCalls != 1 {
|
||||||
|
t.Fatalf("changeCalls after idempotent call = %d, want 1", java.changeCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&model.BinanceRechargeVerifyRecord{}).Where("status = ?", verifyStatusSuccess).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count success records: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("success record count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBinanceRechargeTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.BinanceRechargeConfig{},
|
||||||
|
&model.BinanceRechargeRate{},
|
||||||
|
&model.BinanceRechargeVerifyRecord{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("auto migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeBinanceVerifier struct{}
|
||||||
|
|
||||||
|
func (f *fakeBinanceVerifier) Verify(_ context.Context, _ BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error) {
|
||||||
|
return VerifiedTransaction{
|
||||||
|
TransactionID: query.BillNo,
|
||||||
|
OrderType: query.TransferType,
|
||||||
|
Amount: query.Amount,
|
||||||
|
RawJSON: `{"transactionId":"` + query.BillNo + `"}`,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeBinanceJavaGateway struct {
|
||||||
|
profile integration.UserProfile
|
||||||
|
balanceExists bool
|
||||||
|
sellerExists bool
|
||||||
|
changeCalls int
|
||||||
|
lastChange integration.FreightBalanceChangeRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) GetUserProfile(_ context.Context, userID int64) (integration.UserProfile, error) {
|
||||||
|
if int64(f.profile.ID) == 0 {
|
||||||
|
f.profile.ID = integration.Int64Value(userID)
|
||||||
|
}
|
||||||
|
return f.profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) ExistsFreightBalance(context.Context, int64) (bool, error) {
|
||||||
|
return f.balanceExists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) ExistsFreightSeller(context.Context, int64) (bool, error) {
|
||||||
|
return f.sellerExists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) ChangeFreightBalance(_ context.Context, req integration.FreightBalanceChangeRequest) error {
|
||||||
|
f.changeCalls++
|
||||||
|
f.lastChange = req
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) AddFreightAgentReview(context.Context, integration.FreightAgentReviewRequest) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceJavaGateway) IncreaseFreightRechargeRecord(context.Context, int64, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
150
internal/service/binancerecharge/types.go
Normal file
150
internal/service/binancerecharge/types.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/common"
|
||||||
|
"chatapp3-golang/internal/config"
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransferTypeBinancePay = "BINANCE_PAY"
|
||||||
|
TransferTypeChain = "CHAIN"
|
||||||
|
|
||||||
|
verifyStatusPending = "PENDING"
|
||||||
|
verifyStatusSuccess = "SUCCESS"
|
||||||
|
verifyStatusFailed = "FAILED"
|
||||||
|
|
||||||
|
rechargeTypeUSDT = "USDT-进货"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppError = common.AppError
|
||||||
|
type AuthUser = common.AuthUser
|
||||||
|
|
||||||
|
var NewAppError = common.NewAppError
|
||||||
|
|
||||||
|
type binanceRechargeDB interface {
|
||||||
|
WithContext(ctx context.Context) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type javaGateway interface {
|
||||||
|
GetUserProfile(ctx context.Context, userID int64) (integration.UserProfile, error)
|
||||||
|
ExistsFreightBalance(ctx context.Context, userID int64) (bool, error)
|
||||||
|
ExistsFreightSeller(ctx context.Context, userID int64) (bool, error)
|
||||||
|
ChangeFreightBalance(ctx context.Context, req integration.FreightBalanceChangeRequest) error
|
||||||
|
AddFreightAgentReview(ctx context.Context, req integration.FreightAgentReviewRequest) error
|
||||||
|
IncreaseFreightRechargeRecord(ctx context.Context, userID int64, amount string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type binanceVerifier interface {
|
||||||
|
Verify(ctx context.Context, credential BinanceCredential, query BinanceVerifyQuery) (VerifiedTransaction, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
cfg config.Config
|
||||||
|
db binanceRechargeDB
|
||||||
|
java javaGateway
|
||||||
|
verifier binanceVerifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(cfg config.Config, db binanceRechargeDB, java javaGateway) *Service {
|
||||||
|
return &Service{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
java: java,
|
||||||
|
verifier: NewHTTPBinanceVerifier(cfg.BinanceRecharge.BaseURL, cfg.HTTP.Timeout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetBinanceVerifier(verifier binanceVerifier) {
|
||||||
|
s.verifier = verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateConfigRequest struct {
|
||||||
|
MinAmount string `json:"minAmount"`
|
||||||
|
MaxAmount string `json:"maxAmount"`
|
||||||
|
GoldPerUSD string `json:"goldPerUsd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveConfigRequest struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
APISecret string `json:"apiSecret"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rates []RateConfigRequest `json:"rates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigResponse struct {
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
APISecretConfigured bool `json:"apiSecretConfigured"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Rates []RateConfigResponse `json:"rates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RateConfigResponse struct {
|
||||||
|
ID int64 `json:"id,string,omitempty"`
|
||||||
|
MinAmount string `json:"minAmount"`
|
||||||
|
MaxAmount string `json:"maxAmount"`
|
||||||
|
GoldPerUSD string `json:"goldPerUsd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyRechargeRequest struct {
|
||||||
|
TargetUserID int64 `json:"targetUserId,string"`
|
||||||
|
OrderNo string `json:"orderNo"`
|
||||||
|
TransferAmount string `json:"transferAmount"`
|
||||||
|
TransferType string `json:"transferType"`
|
||||||
|
BillNo string `json:"billNo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyRechargeResponse struct {
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
SysOrigin string `json:"sysOrigin"`
|
||||||
|
DealerUserID int64 `json:"dealerUserId,string"`
|
||||||
|
TargetUserID int64 `json:"targetUserId,string"`
|
||||||
|
OrderNo string `json:"orderNo"`
|
||||||
|
TransferType string `json:"transferType"`
|
||||||
|
BillNo string `json:"billNo"`
|
||||||
|
TransferAmount string `json:"transferAmount"`
|
||||||
|
GoldAmount int64 `json:"goldAmount,string"`
|
||||||
|
RateGoldPerUSD string `json:"rateGoldPerUsd"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Idempotent bool `json:"idempotent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BinanceCredential struct {
|
||||||
|
APIKey string
|
||||||
|
APISecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BinanceVerifyQuery struct {
|
||||||
|
TransferType string
|
||||||
|
BillNo string
|
||||||
|
Amount string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifiedTransaction struct {
|
||||||
|
TransactionID string
|
||||||
|
OrderType string
|
||||||
|
Amount string
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSysOrigin(value string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTransferType(value string) (string, error) {
|
||||||
|
normalized := strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
switch normalized {
|
||||||
|
case TransferTypeBinancePay, TransferTypeChain:
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return "", NewAppError(http.StatusBadRequest, "invalid_transfer_type", "transferType must be BINANCE_PAY or CHAIN")
|
||||||
|
}
|
||||||
|
}
|
||||||
432
internal/service/binancerecharge/verify.go
Normal file
432
internal/service/binancerecharge/verify.go
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
package binancerecharge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chatapp3-golang/internal/integration"
|
||||||
|
"chatapp3-golang/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingRetryAfter = 10 * time.Minute
|
||||||
|
|
||||||
|
var errDuplicateRechargeRecord = errors.New("duplicate binance recharge record")
|
||||||
|
|
||||||
|
type preparedRecharge struct {
|
||||||
|
SysOrigin string
|
||||||
|
DealerUserID int64
|
||||||
|
TargetUserID int64
|
||||||
|
OrderNo string
|
||||||
|
TransferType string
|
||||||
|
BillNo string
|
||||||
|
AmountUSDT string
|
||||||
|
GoldAmount int64
|
||||||
|
RateGoldPerUSD string
|
||||||
|
Verified VerifiedTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) VerifyAndRecharge(ctx context.Context, user AuthUser, req VerifyRechargeRequest) (*VerifyRechargeResponse, error) {
|
||||||
|
if s.db == nil {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable")
|
||||||
|
}
|
||||||
|
if s.java == nil {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable")
|
||||||
|
}
|
||||||
|
if s.verifier == nil {
|
||||||
|
return nil, NewAppError(http.StatusServiceUnavailable, "binance_verifier_unavailable", "binance verifier is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
sysOrigin := normalizeSysOrigin(user.SysOrigin)
|
||||||
|
if sysOrigin == "" {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "sys_origin_required", "sysOrigin is required")
|
||||||
|
}
|
||||||
|
if user.UserID <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "user is invalid")
|
||||||
|
}
|
||||||
|
if req.TargetUserID <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "target_user_required", "targetUserId is required")
|
||||||
|
}
|
||||||
|
orderNo := strings.TrimSpace(req.OrderNo)
|
||||||
|
if orderNo == "" {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "order_no_required", "orderNo is required")
|
||||||
|
}
|
||||||
|
billNo := strings.TrimSpace(req.BillNo)
|
||||||
|
if billNo == "" {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "bill_no_required", "billNo is required")
|
||||||
|
}
|
||||||
|
transferType, err := validateTransferType(req.TransferType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
amountRat, amount, err := parsePositiveDecimal(req.TransferAmount, "transferAmount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, rates, err := s.loadConfigAndRates(ctx, sysOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rateRat, rateGoldPerUSD, err := matchRate(amountRat, rates)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
goldAmount, err := calculateGold(amountRat, rateRat)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if goldAmount <= 0 {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "gold_amount_zero", "calculated gold amount must be greater than 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := s.java.GetUserProfile(ctx, req.TargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "target_user_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if normalizeSysOrigin(profile.OriginSys) != sysOrigin {
|
||||||
|
return nil, NewAppError(http.StatusBadRequest, "target_user_origin_mismatch", "target user does not belong to current sysOrigin")
|
||||||
|
}
|
||||||
|
|
||||||
|
verified, err := s.verifier.Verify(ctx, BinanceCredential{
|
||||||
|
APIKey: cfg.APIKey,
|
||||||
|
APISecret: cfg.APISecret,
|
||||||
|
}, BinanceVerifyQuery{
|
||||||
|
TransferType: transferType,
|
||||||
|
BillNo: billNo,
|
||||||
|
Amount: amount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(verified.TransactionID) == "" {
|
||||||
|
verified.TransactionID = billNo
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(verified.Amount) == "" {
|
||||||
|
verified.Amount = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared := preparedRecharge{
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
DealerUserID: user.UserID,
|
||||||
|
TargetUserID: req.TargetUserID,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
TransferType: transferType,
|
||||||
|
BillNo: billNo,
|
||||||
|
AmountUSDT: amount,
|
||||||
|
GoldAmount: goldAmount,
|
||||||
|
RateGoldPerUSD: rateGoldPerUSD,
|
||||||
|
Verified: verified,
|
||||||
|
}
|
||||||
|
record, idempotent, err := s.prepareRechargeRecord(ctx, prepared)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if idempotent {
|
||||||
|
resp := responseFromRecord(record, true)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.ensureFreightAccount(ctx, sysOrigin, req.TargetUserID); err != nil {
|
||||||
|
_ = s.markRecordFailed(ctx, record.ID, err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.java.ChangeFreightBalance(ctx, integration.FreightBalanceChangeRequest{
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
UserID: req.TargetUserID,
|
||||||
|
AcceptUserID: req.TargetUserID,
|
||||||
|
Type: "INCOME",
|
||||||
|
AcceptAmount: strconv.FormatInt(goldAmount, 10),
|
||||||
|
OrderAmount: amount,
|
||||||
|
USDAmount: "0",
|
||||||
|
Origin: "PURCHASE",
|
||||||
|
Remark: fmt.Sprintf("Binance auto recharge order:%s bill:%s", orderNo, billNo),
|
||||||
|
RechargeType: rechargeTypeUSDT,
|
||||||
|
CreateUser: user.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
_ = s.markRecordPendingIssue(ctx, record.ID, "freight balance change result is unknown: "+err.Error())
|
||||||
|
return nil, NewAppError(http.StatusBadGateway, "freight_balance_change_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := ""
|
||||||
|
failReason := ""
|
||||||
|
if err := s.java.IncreaseFreightRechargeRecord(ctx, req.TargetUserID, amount); err != nil {
|
||||||
|
failReason = truncateString("monthly freight recharge record update failed: "+err.Error(), 500)
|
||||||
|
remark = failReason
|
||||||
|
}
|
||||||
|
if err := s.markRecordSuccess(ctx, record.ID, remark, failReason); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
record.Status = verifyStatusSuccess
|
||||||
|
record.Remark = remark
|
||||||
|
record.FailReason = failReason
|
||||||
|
resp := responseFromRecord(record, false)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadConfigAndRates(ctx context.Context, sysOrigin string) (model.BinanceRechargeConfig, []model.BinanceRechargeRate, error) {
|
||||||
|
var cfg model.BinanceRechargeConfig
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("sys_origin = ?", sysOrigin).
|
||||||
|
First(&cfg).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_missing", "binance recharge config is missing")
|
||||||
|
}
|
||||||
|
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_config_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_config_disabled", "binance recharge verification is disabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.APIKey) == "" || strings.TrimSpace(cfg.APISecret) == "" {
|
||||||
|
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_credentials_missing", "binance api key and secret are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rates []model.BinanceRechargeRate
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("config_id = ?", cfg.ID).
|
||||||
|
Order("sort_order asc, min_amount asc, id asc").
|
||||||
|
Find(&rates).Error; err != nil {
|
||||||
|
return cfg, nil, NewAppError(http.StatusInternalServerError, "binance_rate_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if len(rates) == 0 {
|
||||||
|
return cfg, nil, NewAppError(http.StatusBadRequest, "binance_rates_missing", "binance recharge rates are missing")
|
||||||
|
}
|
||||||
|
return cfg, rates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchRate(amount *big.Rat, rates []model.BinanceRechargeRate) (*big.Rat, string, error) {
|
||||||
|
for _, row := range rates {
|
||||||
|
minRat, _, err := parseNonNegativeDecimal(row.MinAmount, "minAmount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
maxRat, _, err := parseNonNegativeDecimal(defaultZero(row.MaxAmount), "maxAmount")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
rateRat, rateGoldPerUSD, err := parsePositiveDecimal(row.GoldPerUSD, "goldPerUsd")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if amount.Cmp(minRat) < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if maxRat.Sign() > 0 && amount.Cmp(maxRat) >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return rateRat, rateGoldPerUSD, nil
|
||||||
|
}
|
||||||
|
return nil, "", NewAppError(http.StatusBadRequest, "binance_rate_not_matched", "transfer amount does not match any configured rate range")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) prepareRechargeRecord(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
|
||||||
|
var record model.BinanceRechargeVerifyRecord
|
||||||
|
var idempotent bool
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
record, idempotent, err = s.prepareRechargeRecordOnce(ctx, prepared)
|
||||||
|
if errors.Is(err, errDuplicateRechargeRecord) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return record, idempotent, err
|
||||||
|
}
|
||||||
|
return record, idempotent, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) prepareRechargeRecordOnce(ctx context.Context, prepared preparedRecharge) (model.BinanceRechargeVerifyRecord, bool, error) {
|
||||||
|
var out model.BinanceRechargeVerifyRecord
|
||||||
|
var idempotent bool
|
||||||
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var existing []model.BinanceRechargeVerifyRecord
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Where("sys_origin = ? AND (order_no = ? OR (transfer_type = ? AND bill_no = ?))",
|
||||||
|
prepared.SysOrigin,
|
||||||
|
prepared.OrderNo,
|
||||||
|
prepared.TransferType,
|
||||||
|
prepared.BillNo,
|
||||||
|
).
|
||||||
|
Limit(2).
|
||||||
|
Find(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(existing) > 1 {
|
||||||
|
return NewAppError(http.StatusConflict, "recharge_order_or_bill_used", "orderNo or billNo has already been used")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if len(existing) == 1 {
|
||||||
|
record := existing[0]
|
||||||
|
switch record.Status {
|
||||||
|
case verifyStatusSuccess:
|
||||||
|
out = record
|
||||||
|
idempotent = true
|
||||||
|
return nil
|
||||||
|
case verifyStatusPending:
|
||||||
|
if now.Sub(record.UpdateTime) < pendingRetryAfter {
|
||||||
|
return NewAppError(http.StatusConflict, "recharge_verification_processing", "recharge verification is processing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates := recordValues(prepared, now, verifyStatusPending)
|
||||||
|
if err := tx.Model(&model.BinanceRechargeVerifyRecord{}).
|
||||||
|
Where("id = ?", record.ID).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", record.ID).First(&out).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
record := model.BinanceRechargeVerifyRecord{
|
||||||
|
SysOrigin: prepared.SysOrigin,
|
||||||
|
DealerUserID: prepared.DealerUserID,
|
||||||
|
TargetUserID: prepared.TargetUserID,
|
||||||
|
OrderNo: prepared.OrderNo,
|
||||||
|
TransferType: prepared.TransferType,
|
||||||
|
BillNo: prepared.BillNo,
|
||||||
|
AmountUSDT: prepared.AmountUSDT,
|
||||||
|
GoldAmount: prepared.GoldAmount,
|
||||||
|
RateGoldPerUSD: prepared.RateGoldPerUSD,
|
||||||
|
BinanceTransactionID: prepared.Verified.TransactionID,
|
||||||
|
BinanceOrderType: prepared.Verified.OrderType,
|
||||||
|
BinanceRawJSON: prepared.Verified.RawJSON,
|
||||||
|
Status: verifyStatusPending,
|
||||||
|
CreateTime: now,
|
||||||
|
UpdateTime: now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&record).Error; err != nil {
|
||||||
|
if isDuplicateRecordError(err) {
|
||||||
|
return errDuplicateRechargeRecord
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = record
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var appErr *AppError
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
return out, idempotent, err
|
||||||
|
}
|
||||||
|
return out, idempotent, NewAppError(http.StatusInternalServerError, "recharge_record_prepare_failed", err.Error())
|
||||||
|
}
|
||||||
|
return out, idempotent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordValues(prepared preparedRecharge, now time.Time, status string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"dealer_user_id": prepared.DealerUserID,
|
||||||
|
"target_user_id": prepared.TargetUserID,
|
||||||
|
"order_no": prepared.OrderNo,
|
||||||
|
"transfer_type": prepared.TransferType,
|
||||||
|
"bill_no": prepared.BillNo,
|
||||||
|
"amount_usdt": prepared.AmountUSDT,
|
||||||
|
"gold_amount": prepared.GoldAmount,
|
||||||
|
"rate_gold_per_usd": prepared.RateGoldPerUSD,
|
||||||
|
"binance_transaction_id": prepared.Verified.TransactionID,
|
||||||
|
"binance_order_type": prepared.Verified.OrderType,
|
||||||
|
"binance_raw_json": prepared.Verified.RawJSON,
|
||||||
|
"status": status,
|
||||||
|
"fail_reason": "",
|
||||||
|
"remark": "",
|
||||||
|
"update_time": now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureFreightAccount(ctx context.Context, sysOrigin string, targetUserID int64) error {
|
||||||
|
exists, err := s.java.ExistsFreightBalance(ctx, targetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return NewAppError(http.StatusBadGateway, "freight_balance_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existsSeller, err := s.java.ExistsFreightSeller(ctx, targetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return NewAppError(http.StatusBadGateway, "freight_seller_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if existsSeller {
|
||||||
|
return NewAppError(http.StatusConflict, "freight_seller_exists_without_balance", "target user is already a freight seller without balance account")
|
||||||
|
}
|
||||||
|
if err := s.java.AddFreightAgentReview(ctx, integration.FreightAgentReviewRequest{
|
||||||
|
SysOrigin: sysOrigin,
|
||||||
|
UserID: targetUserID,
|
||||||
|
}); err != nil {
|
||||||
|
return NewAppError(http.StatusBadGateway, "freight_agent_create_failed", err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markRecordFailed(ctx context.Context, id int64, reason string) error {
|
||||||
|
return s.updateRecordStatus(ctx, id, verifyStatusFailed, "", truncateString(reason, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markRecordPendingIssue(ctx context.Context, id int64, reason string) error {
|
||||||
|
return s.updateRecordStatus(ctx, id, verifyStatusPending, "", truncateString(reason, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) markRecordSuccess(ctx context.Context, id int64, remark string, failReason string) error {
|
||||||
|
return s.updateRecordStatus(ctx, id, verifyStatusSuccess, truncateString(remark, 500), truncateString(failReason, 500))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateRecordStatus(ctx context.Context, id int64, status string, remark string, failReason string) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&model.BinanceRechargeVerifyRecord{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"remark": remark,
|
||||||
|
"fail_reason": failReason,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return NewAppError(http.StatusInternalServerError, "recharge_record_update_failed", err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFromRecord(record model.BinanceRechargeVerifyRecord, idempotent bool) VerifyRechargeResponse {
|
||||||
|
return VerifyRechargeResponse{
|
||||||
|
ID: record.ID,
|
||||||
|
SysOrigin: record.SysOrigin,
|
||||||
|
DealerUserID: record.DealerUserID,
|
||||||
|
TargetUserID: record.TargetUserID,
|
||||||
|
OrderNo: record.OrderNo,
|
||||||
|
TransferType: record.TransferType,
|
||||||
|
BillNo: record.BillNo,
|
||||||
|
TransferAmount: trimDecimalZeros(record.AmountUSDT),
|
||||||
|
GoldAmount: record.GoldAmount,
|
||||||
|
RateGoldPerUSD: trimDecimalZeros(record.RateGoldPerUSD),
|
||||||
|
Status: record.Status,
|
||||||
|
Idempotent: idempotent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicateRecordError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(message, "duplicate") ||
|
||||||
|
strings.Contains(message, "unique constraint") ||
|
||||||
|
strings.Contains(message, "duplicated key")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(value string, limit int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if limit <= 0 || len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:limit]
|
||||||
|
}
|
||||||
@ -243,6 +243,9 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
|
|||||||
}
|
}
|
||||||
return nil, serverError("user_query_failed", err.Error())
|
return nil, serverError("user_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
|
if isUnbannableAccountStatus(target.AccountStatus) {
|
||||||
|
return nil, badRequest("user_already_banned", "user is already banned")
|
||||||
|
}
|
||||||
|
|
||||||
hasRecharge, err := s.userHasRecharge(ctx, target.ID)
|
hasRecharge, err := s.userHasRecharge(ctx, target.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -269,6 +272,44 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserRequest) (*UnbanUserResponse, error) {
|
||||||
|
if err := s.ensureManager(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.UserID.Int64() <= 0 {
|
||||||
|
return nil, badRequest("user_required", "userId is required")
|
||||||
|
}
|
||||||
|
if s.java == nil {
|
||||||
|
return nil, serviceUnavailable("java_gateway_unavailable", "java gateway is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, notFound("user_not_found", "user not found")
|
||||||
|
}
|
||||||
|
return nil, serverError("user_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if !isUnbannableAccountStatus(target.AccountStatus) {
|
||||||
|
return nil, badRequest("user_not_banned", "only banned or frozen users can be unbanned")
|
||||||
|
}
|
||||||
|
|
||||||
|
description := buildUnbanDescription(user.UserID, req.Reason)
|
||||||
|
if err := s.java.UnblockAccount(ctx, integration.UnblockAccountRequest{
|
||||||
|
BeOptUserID: target.ID,
|
||||||
|
OptUserID: user.UserID,
|
||||||
|
Description: description,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, serverError("unban_user_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UnbanUserResponse{
|
||||||
|
Success: true,
|
||||||
|
UserID: req.UserID,
|
||||||
|
AccountStatus: accountStatusNormal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
|
||||||
if user.UserID <= 0 {
|
if user.UserID <= 0 {
|
||||||
return unauthorized("invalid_user", "authenticated user is required")
|
return unauthorized("invalid_user", "authenticated user is required")
|
||||||
@ -358,6 +399,7 @@ func (s *Service) loadUserRowByAccount(ctx context.Context, account string) (use
|
|||||||
|
|
||||||
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserView, error) {
|
||||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID)
|
||||||
|
canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus)
|
||||||
return UserView{
|
return UserView{
|
||||||
UserID: ID(row.ID),
|
UserID: ID(row.ID),
|
||||||
Account: row.Account,
|
Account: row.Account,
|
||||||
@ -374,6 +416,7 @@ func (s *Service) userRowToView(ctx context.Context, row userBaseInfoRow) (UserV
|
|||||||
|
|
||||||
func (s *Service) javaProfileToView(ctx context.Context, profile integration.UserProfile) (UserView, error) {
|
func (s *Service) javaProfileToView(ctx context.Context, profile integration.UserProfile) (UserView, error) {
|
||||||
hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID))
|
hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID))
|
||||||
|
canBan = canBan && !isUnbannableAccountStatus(profile.AccountStatus)
|
||||||
return UserView{
|
return UserView{
|
||||||
UserID: ID(profile.ID),
|
UserID: ID(profile.ID),
|
||||||
Account: profile.Account,
|
Account: profile.Account,
|
||||||
@ -409,6 +452,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) {
|
|||||||
}
|
}
|
||||||
if profile.AccountStatus != "" {
|
if profile.AccountStatus != "" {
|
||||||
view.AccountStatus = profile.AccountStatus
|
view.AccountStatus = profile.AccountStatus
|
||||||
|
if isUnbannableAccountStatus(profile.AccountStatus) {
|
||||||
|
view.CanBan = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,6 +661,27 @@ func buildBanDescription(managerID int64, reason string) string {
|
|||||||
return fmt.Sprintf("manager center ban non-recharged user, manager=%d, reason=%s", managerID, reason)
|
return fmt.Sprintf("manager center ban non-recharged user, manager=%d, reason=%s", managerID, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildUnbanDescription(managerID int64, reason string) string {
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if len([]rune(reason)) > 200 {
|
||||||
|
runes := []rune(reason)
|
||||||
|
reason = string(runes[:200])
|
||||||
|
}
|
||||||
|
if reason == "" {
|
||||||
|
return fmt.Sprintf("manager center unban user, manager=%d", managerID)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("manager center unban user, manager=%d, reason=%s", managerID, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnbannableAccountStatus(status string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||||
|
case accountStatusFreeze, accountStatusArchive, accountStatusArchiveDevice:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isAppErrorCode(err error, code string) bool {
|
func isAppErrorCode(err error, code string) bool {
|
||||||
var appErr *AppError
|
var appErr *AppError
|
||||||
return errors.As(err, &appErr) && appErr.Code == code
|
return errors.As(err, &appErr) && appErr.Code == code
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package managercenter
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -14,15 +15,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeManagerGateway struct {
|
type fakeManagerGateway struct {
|
||||||
recharges map[int64][]integration.UserTotalRecharge
|
recharges map[int64][]integration.UserTotalRecharge
|
||||||
rechargeErr error
|
rechargeErr error
|
||||||
abilities map[int64]integration.PropsNobleVIPAbility
|
abilities map[int64]integration.PropsNobleVIPAbility
|
||||||
maxAbility integration.PropsNobleVIPAbility
|
maxAbility integration.PropsNobleVIPAbility
|
||||||
giveRequests []integration.GivePropsBackpackRequest
|
giveRequests []integration.GivePropsBackpackRequest
|
||||||
punishRequests []integration.PunishAccountRequest
|
punishRequests []integration.PunishAccountRequest
|
||||||
switchIDs []int64
|
unblockRequests []integration.UnblockAccountRequest
|
||||||
cacheCleared []int64
|
switchIDs []int64
|
||||||
badgeIDs []int64
|
cacheCleared []int64
|
||||||
|
badgeIDs []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) {
|
func (f *fakeManagerGateway) GetUserProfile(context.Context, int64) (integration.UserProfile, error) {
|
||||||
@ -77,6 +79,11 @@ func (f *fakeManagerGateway) PunishAccount(_ context.Context, req integration.Pu
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeManagerGateway) UnblockAccount(_ context.Context, req integration.UnblockAccountRequest) error {
|
||||||
|
f.unblockRequests = append(f.unblockRequests, req)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) {
|
||||||
service, _ := newManagerCenterTestService(t)
|
service, _ := newManagerCenterTestService(t)
|
||||||
seedManager(t, service.db.(*gorm.DB), 1)
|
seedManager(t, service.db.(*gorm.DB), 1)
|
||||||
@ -294,6 +301,32 @@ func TestBanUserFailsWhenRechargeQueryUnavailable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBanUserRejectsArchivedUser(t *testing.T) {
|
||||||
|
service, fake := newManagerCenterTestService(t)
|
||||||
|
db := service.db.(*gorm.DB)
|
||||||
|
seedManager(t, db, 1)
|
||||||
|
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
||||||
|
|
||||||
|
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||||
|
assertAppErrorCode(t, err, "user_already_banned")
|
||||||
|
if len(fake.punishRequests) != 0 {
|
||||||
|
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanUserRejectsFrozenUser(t *testing.T) {
|
||||||
|
service, fake := newManagerCenterTestService(t)
|
||||||
|
db := service.db.(*gorm.DB)
|
||||||
|
seedManager(t, db, 1)
|
||||||
|
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusFreeze})
|
||||||
|
|
||||||
|
_, err := service.BanUser(context.Background(), AuthUser{UserID: 1}, BanUserRequest{UserID: ID(2)})
|
||||||
|
assertAppErrorCode(t, err, "user_already_banned")
|
||||||
|
if len(fake.punishRequests) != 0 {
|
||||||
|
t.Fatalf("punish requests = %+v, want none", fake.punishRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
||||||
service, fake := newManagerCenterTestService(t)
|
service, fake := newManagerCenterTestService(t)
|
||||||
db := service.db.(*gorm.DB)
|
db := service.db.(*gorm.DB)
|
||||||
@ -319,6 +352,65 @@ func TestBanUserWithoutRechargeCallsPunishment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnbanUserRejectsNormalUser(t *testing.T) {
|
||||||
|
service, fake := newManagerCenterTestService(t)
|
||||||
|
db := service.db.(*gorm.DB)
|
||||||
|
seedManager(t, db, 1)
|
||||||
|
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusNormal})
|
||||||
|
|
||||||
|
_, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
||||||
|
assertAppErrorCode(t, err, "user_not_banned")
|
||||||
|
if len(fake.unblockRequests) != 0 {
|
||||||
|
t.Fatalf("unblock requests = %+v, want none", fake.unblockRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbanUserCallsUnblockAccount(t *testing.T) {
|
||||||
|
service, fake := newManagerCenterTestService(t)
|
||||||
|
db := service.db.(*gorm.DB)
|
||||||
|
seedManager(t, db, 1)
|
||||||
|
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusArchive})
|
||||||
|
|
||||||
|
resp, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{
|
||||||
|
UserID: ID(2),
|
||||||
|
Reason: "appeal passed",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnbanUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success || resp.AccountStatus != accountStatusNormal {
|
||||||
|
t.Fatalf("resp = %+v, want normal success", resp)
|
||||||
|
}
|
||||||
|
if len(fake.unblockRequests) != 1 {
|
||||||
|
t.Fatalf("unblock requests = %+v, want one", fake.unblockRequests)
|
||||||
|
}
|
||||||
|
got := fake.unblockRequests[0]
|
||||||
|
if got.BeOptUserID != 2 || got.OptUserID != 1 {
|
||||||
|
t.Fatalf("unblock request = %+v, want target 2 manager 1", got)
|
||||||
|
}
|
||||||
|
if got.Description == "" || !strings.Contains(got.Description, "appeal passed") {
|
||||||
|
t.Fatalf("description = %q, want reason included", got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbanUserAllowsFrozenUser(t *testing.T) {
|
||||||
|
service, fake := newManagerCenterTestService(t)
|
||||||
|
db := service.db.(*gorm.DB)
|
||||||
|
seedManager(t, db, 1)
|
||||||
|
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2", AccountStatus: accountStatusFreeze})
|
||||||
|
|
||||||
|
resp, err := service.UnbanUser(context.Background(), AuthUser{UserID: 1}, UnbanUserRequest{UserID: ID(2)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnbanUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success || resp.AccountStatus != accountStatusNormal {
|
||||||
|
t.Fatalf("resp = %+v, want normal success", resp)
|
||||||
|
}
|
||||||
|
if len(fake.unblockRequests) != 1 {
|
||||||
|
t.Fatalf("unblock requests = %+v, want one", fake.unblockRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
|||||||
@ -30,7 +30,10 @@ const (
|
|||||||
defaultGiftDays = 7
|
defaultGiftDays = 7
|
||||||
defaultVIPGiftDays = 30
|
defaultVIPGiftDays = 30
|
||||||
|
|
||||||
accountStatusArchive = "ARCHIVE"
|
accountStatusNormal = "NORMAL"
|
||||||
|
accountStatusFreeze = "FREEZE"
|
||||||
|
accountStatusArchive = "ARCHIVE"
|
||||||
|
accountStatusArchiveDevice = "ARCHIVE_DEVICE"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppError = common.AppError
|
type AppError = common.AppError
|
||||||
@ -53,6 +56,7 @@ type managerGateway interface {
|
|||||||
ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error
|
ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error
|
||||||
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
|
RemoveUserProfileCacheAll(ctx context.Context, userID int64) error
|
||||||
PunishAccount(ctx context.Context, req integration.PunishAccountRequest) error
|
PunishAccount(ctx context.Context, req integration.PunishAccountRequest) error
|
||||||
|
UnblockAccount(ctx context.Context, req integration.UnblockAccountRequest) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -162,6 +166,17 @@ type BanUserResponse struct {
|
|||||||
AccountStatus string `json:"accountStatus"`
|
AccountStatus string `json:"accountStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnbanUserRequest struct {
|
||||||
|
UserID ID `json:"userId"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnbanUserResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
UserID ID `json:"userId"`
|
||||||
|
AccountStatus string `json:"accountStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
func badRequest(code, message string) error {
|
func badRequest(code, message string) error {
|
||||||
return NewAppError(http.StatusBadRequest, code, message)
|
return NewAppError(http.StatusBadRequest, code, message)
|
||||||
}
|
}
|
||||||
|
|||||||
52
migrations/031_binance_recharge.sql
Normal file
52
migrations/031_binance_recharge.sql
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `binance_recharge_config` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||||
|
`api_key` varchar(255) NOT NULL DEFAULT '' COMMENT 'Binance API Key',
|
||||||
|
`api_secret` varchar(255) NOT NULL DEFAULT '' COMMENT 'Binance API Secret',
|
||||||
|
`enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用自动充值验证',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_binance_recharge_config_origin` (`sys_origin`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值配置';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `binance_recharge_rate` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`config_id` bigint NOT NULL COMMENT '配置ID',
|
||||||
|
`min_amount` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '最小USDT金额,包含',
|
||||||
|
`max_amount` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '最大USDT金额,不包含;0表示不限制',
|
||||||
|
`gold_per_usd` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '1 USDT 对应金币',
|
||||||
|
`sort_order` int NOT NULL DEFAULT 0,
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_binance_recharge_rate_config` (`config_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值金币区间';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `binance_recharge_verify_record` (
|
||||||
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
|
`sys_origin` varchar(32) NOT NULL COMMENT '系统来源',
|
||||||
|
`dealer_user_id` bigint NOT NULL COMMENT '提交验证的币商用户ID',
|
||||||
|
`target_user_id` bigint NOT NULL COMMENT '发放用户ID',
|
||||||
|
`order_no` varchar(128) NOT NULL COMMENT '业务订单号',
|
||||||
|
`transfer_type` varchar(32) NOT NULL COMMENT 'BINANCE_PAY/CHAIN',
|
||||||
|
`bill_no` varchar(160) NOT NULL COMMENT '币安账单号/链上txId',
|
||||||
|
`amount_usdt` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '验证USDT金额',
|
||||||
|
`gold_amount` bigint NOT NULL DEFAULT 0 COMMENT '发放金币',
|
||||||
|
`rate_gold_per_usd` decimal(20,8) NOT NULL DEFAULT 0 COMMENT '命中金币比例',
|
||||||
|
`binance_transaction_id` varchar(180) NOT NULL DEFAULT '' COMMENT '币安交易ID',
|
||||||
|
`binance_order_type` varchar(64) NOT NULL DEFAULT '' COMMENT '币安交易类型',
|
||||||
|
`binance_raw_json` text COMMENT '币安原始记录',
|
||||||
|
`status` varchar(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED',
|
||||||
|
`fail_reason` varchar(500) NOT NULL DEFAULT '',
|
||||||
|
`remark` varchar(500) NOT NULL DEFAULT '',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_binance_recharge_verify_order` (`sys_origin`, `order_no`),
|
||||||
|
UNIQUE KEY `uk_binance_recharge_verify_bill` (`sys_origin`, `transfer_type`, `bill_no`),
|
||||||
|
KEY `idx_binance_recharge_verify_origin` (`sys_origin`),
|
||||||
|
KEY `idx_binance_recharge_verify_dealer` (`dealer_user_id`),
|
||||||
|
KEY `idx_binance_recharge_verify_target` (`target_user_id`),
|
||||||
|
KEY `idx_binance_recharge_verify_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币安自动充值验证记录';
|
||||||
Loading…
x
Reference in New Issue
Block a user