diff --git a/cmd/api/main.go b/cmd/api/main.go index 443d09c..eb8362b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,6 +14,7 @@ import ( "chatapp3-golang/internal/router" "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -45,6 +46,7 @@ func main() { appPopupService := apppopup.NewService(app.Config, app.Repository.DB, app.Repository.Redis) 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) + binanceRechargeService := binancerecharge.NewService(app.Config, app.Repository.DB, &app.Gateways) lingxianService := lingxian.NewService(app.Config, app.Repository.DB, app.Repository.Redis) gameOpenService := gameopen.NewGameOpenService(app.Config, app.Repository.DB, &app.Gateways) luckyGiftService := luckygift.NewLuckyGiftService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet) @@ -90,6 +92,7 @@ func main() { AppPopup: appPopupService, Invite: inviteService, Baishun: baishunService, + BinanceRecharge: binanceRechargeService, Lingxian: lingxianService, GameOpen: gameOpenService, GameProviders: gameProviders, diff --git a/internal/config/config.go b/internal/config/config.go index d8a741a..510c2a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ type Config struct { Lingxian LingxianConfig GameOpen GameOpenConfig LuckyGift LuckyGiftConfig + BinanceRecharge BinanceRechargeConfig } // HTTPConfig 保存 HTTP 服务运行配置。 @@ -204,6 +205,11 @@ type LuckyGiftConfig struct { Providers []LuckyGiftProviderConfig } +// BinanceRechargeConfig 保存币安自动充值验证的运行配置。 +type BinanceRechargeConfig struct { + BaseURL string +} + type LuckyGiftProviderConfig struct { StandardID int64 `json:"standardId"` 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"), Providers: loadLuckyGiftConfigs(), }, + BinanceRecharge: BinanceRechargeConfig{ + BaseURL: getEnvAny([]string{"CHATAPP_BINANCE_RECHARGE_BASE_URL", "BINANCE_RECHARGE_BASE_URL"}, "https://api.binance.com"), + }, } } diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index 72b49db..f8d866e 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -166,6 +166,11 @@ func (g *Gateways) PunishAccount(ctx context.Context, req PunishAccountRequest) return g.Profile.PunishAccount(ctx, req) } +// UnblockAccount 透传到账号解封接口。 +func (g *Gateways) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error { + return g.Profile.UnblockAccount(ctx, req) +} + // ListCurrentWeekRoomContribution 透传到房间流水网关。 func (g *Gateways) ListCurrentWeekRoomContribution(ctx context.Context, limit int) ([]RoomContributionActivityCount, error) { 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) } +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 负责认证相关接口。 type AuthGateway struct { client *Client @@ -276,6 +301,10 @@ func (g *OrderGateway) GetThisMonthTotalPersonalRecharge(ctx context.Context, us 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 负责活动奖励发放接口。 type RewardDispatchGateway struct { client *Client @@ -376,6 +405,11 @@ func (g *ProfileGateway) PunishAccount(ctx context.Context, req PunishAccountReq return g.client.PunishAccount(ctx, req) } +// UnblockAccount 解封用户账号。 +func (g *ProfileGateway) UnblockAccount(ctx context.Context, req UnblockAccountRequest) error { + return g.client.UnblockAccount(ctx, req) +} + // RoomGateway 负责房间资料和房间流水相关接口。 type RoomGateway struct { 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) { 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) +} diff --git a/internal/integration/java.go b/internal/integration/java.go index 3665672..0c01a8c 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -179,6 +179,12 @@ type PunishAccountRequest struct { Description string `json:"description"` } +type UnblockAccountRequest struct { + BeOptUserID int64 `json:"beOptUserId"` + OptUserID int64 `json:"optUserId"` + Description string `json:"description"` +} + type SendActivityRewardRequest struct { TrackID int64 `json:"trackId"` Origin string `json:"origin"` @@ -296,6 +302,25 @@ type FreightSellerProfile struct { 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 { Status *bool `json:"status"` ErrorMsg string `json:"errorMsg"` @@ -746,6 +771,94 @@ func (c *Client) PageFreightSellers(ctx context.Context, authorization string, s 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) { endpoint := c.cfg.Java.OtherBaseURL + "/user-profile/client/getByUserId?userId=" + url.QueryEscape(strconv.FormatInt(userID, 10)) 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) } +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) { endpoint := c.cfg.Java.WalletBaseURL + "/wallet/gold/client/mapBalance" var resp resultResponse[map[string]int64] diff --git a/internal/model/binance_recharge_models.go b/internal/model/binance_recharge_models.go new file mode 100644 index 0000000..24ef2b5 --- /dev/null +++ b/internal/model/binance_recharge_models.go @@ -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" +} diff --git a/internal/router/binance_recharge_routes.go b/internal/router/binance_recharge_routes.go new file mode 100644 index 0000000..8f36bb7 --- /dev/null +++ b/internal/router/binance_recharge_routes.go @@ -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) + }) +} diff --git a/internal/router/manager_center_routes.go b/internal/router/manager_center_routes.go index d54895a..b38408d 100644 --- a/internal/router/manager_center_routes.go +++ b/internal/router/manager_center_routes.go @@ -73,4 +73,18 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser } 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) + }) } diff --git a/internal/router/router.go b/internal/router/router.go index df47e4e..a252ecf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,6 +8,7 @@ import ( "chatapp3-golang/internal/repo" "chatapp3-golang/internal/service/apppopup" "chatapp3-golang/internal/service/baishun" + "chatapp3-golang/internal/service/binancerecharge" "chatapp3-golang/internal/service/gameopen" "chatapp3-golang/internal/service/gameprovider" "chatapp3-golang/internal/service/hostcenter" @@ -34,6 +35,7 @@ type Services struct { AppPopup *apppopup.Service Invite *invite.InviteService Baishun *baishun.BaishunService + BinanceRecharge *binancerecharge.Service Lingxian *lingxian.Service GameOpen *gameopen.GameOpenService GameProviders *gameprovider.Registry @@ -67,6 +69,7 @@ func NewRouter( registerInviteRoutes(engine, javaClient, services.Invite) registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) + registerBinanceRechargeRoutes(engine, javaClient, services.BinanceRecharge) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) registerTaskCenterRoutes(engine, cfg, javaClient, services.TaskCenter) diff --git a/internal/service/binancerecharge/amount.go b/internal/service/binancerecharge/amount.go new file mode 100644 index 0000000..9a3bfbb --- /dev/null +++ b/internal/service/binancerecharge/amount.go @@ -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 +} diff --git a/internal/service/binancerecharge/binance.go b/internal/service/binancerecharge/binance.go new file mode 100644 index 0000000..4555da3 --- /dev/null +++ b/internal/service/binancerecharge/binance.go @@ -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 "" + } +} diff --git a/internal/service/binancerecharge/config.go b/internal/service/binancerecharge/config.go new file mode 100644 index 0000000..6e192e5 --- /dev/null +++ b/internal/service/binancerecharge/config.go @@ -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 +} diff --git a/internal/service/binancerecharge/service_test.go b/internal/service/binancerecharge/service_test.go new file mode 100644 index 0000000..ea095c2 --- /dev/null +++ b/internal/service/binancerecharge/service_test.go @@ -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 +} diff --git a/internal/service/binancerecharge/types.go b/internal/service/binancerecharge/types.go new file mode 100644 index 0000000..c56a546 --- /dev/null +++ b/internal/service/binancerecharge/types.go @@ -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") + } +} diff --git a/internal/service/binancerecharge/verify.go b/internal/service/binancerecharge/verify.go new file mode 100644 index 0000000..267993d --- /dev/null +++ b/internal/service/binancerecharge/verify.go @@ -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] +} diff --git a/internal/service/managercenter/service.go b/internal/service/managercenter/service.go index 3424421..4d844bd 100644 --- a/internal/service/managercenter/service.go +++ b/internal/service/managercenter/service.go @@ -243,6 +243,9 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest } 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) if err != nil { @@ -269,6 +272,44 @@ func (s *Service) BanUser(ctx context.Context, user AuthUser, req BanUserRequest }, 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 { if user.UserID <= 0 { 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) { hasRecharge, canBan := s.userRechargeEligibility(ctx, row.ID) + canBan = canBan && !isUnbannableAccountStatus(row.AccountStatus) return UserView{ UserID: ID(row.ID), 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) { hasRecharge, canBan := s.userRechargeEligibility(ctx, int64(profile.ID)) + canBan = canBan && !isUnbannableAccountStatus(profile.AccountStatus) return UserView{ UserID: ID(profile.ID), Account: profile.Account, @@ -409,6 +452,9 @@ func overlayJavaProfile(view *UserView, profile integration.UserProfile) { } if 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) } +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 { var appErr *AppError return errors.As(err, &appErr) && appErr.Code == code diff --git a/internal/service/managercenter/service_test.go b/internal/service/managercenter/service_test.go index 390397d..9726eeb 100644 --- a/internal/service/managercenter/service_test.go +++ b/internal/service/managercenter/service_test.go @@ -3,6 +3,7 @@ package managercenter import ( "context" "errors" + "strings" "testing" "time" @@ -14,15 +15,16 @@ import ( ) type fakeManagerGateway struct { - recharges map[int64][]integration.UserTotalRecharge - rechargeErr error - abilities map[int64]integration.PropsNobleVIPAbility - maxAbility integration.PropsNobleVIPAbility - giveRequests []integration.GivePropsBackpackRequest - punishRequests []integration.PunishAccountRequest - switchIDs []int64 - cacheCleared []int64 - badgeIDs []int64 + recharges map[int64][]integration.UserTotalRecharge + rechargeErr error + abilities map[int64]integration.PropsNobleVIPAbility + maxAbility integration.PropsNobleVIPAbility + giveRequests []integration.GivePropsBackpackRequest + punishRequests []integration.PunishAccountRequest + unblockRequests []integration.UnblockAccountRequest + switchIDs []int64 + cacheCleared []int64 + badgeIDs []int64 } 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 } +func (f *fakeManagerGateway) UnblockAccount(_ context.Context, req integration.UnblockAccountRequest) error { + f.unblockRequests = append(f.unblockRequests, req) + return nil +} + func TestListPropsRequiresAdminFreeAndFiltersVIPLevels(t *testing.T) { service, _ := newManagerCenterTestService(t) 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) { service, fake := newManagerCenterTestService(t) 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) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/internal/service/managercenter/types.go b/internal/service/managercenter/types.go index 85be6f2..dd76e7b 100644 --- a/internal/service/managercenter/types.go +++ b/internal/service/managercenter/types.go @@ -30,7 +30,10 @@ const ( defaultGiftDays = 7 defaultVIPGiftDays = 30 - accountStatusArchive = "ARCHIVE" + accountStatusNormal = "NORMAL" + accountStatusFreeze = "FREEZE" + accountStatusArchive = "ARCHIVE" + accountStatusArchiveDevice = "ARCHIVE_DEVICE" ) type AppError = common.AppError @@ -53,6 +56,7 @@ type managerGateway interface { ActivateTemporaryBadge(ctx context.Context, userID int64, badgeID int64, days int) error RemoveUserProfileCacheAll(ctx context.Context, userID int64) error PunishAccount(ctx context.Context, req integration.PunishAccountRequest) error + UnblockAccount(ctx context.Context, req integration.UnblockAccountRequest) error } type Service struct { @@ -162,6 +166,17 @@ type BanUserResponse struct { 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 { return NewAppError(http.StatusBadRequest, code, message) } diff --git a/migrations/031_binance_recharge.sql b/migrations/031_binance_recharge.sql new file mode 100644 index 0000000..7c06e98 --- /dev/null +++ b/migrations/031_binance_recharge.sql @@ -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='币安自动充值验证记录';