经理中心以及币安充值

This commit is contained in:
hy001 2026-05-12 14:08:46 +08:00
parent 47a7725b33
commit 171ba88a2e
18 changed files with 1911 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View 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"
}

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

View File

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

View File

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

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

View 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 ""
}
}

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

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

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

View 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]
}

View File

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

View File

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

View File

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

View 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='币安自动充值验证记录';