diff --git a/cmd/api/main.go b/cmd/api/main.go index a0bd2c3..8adf7cb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -19,6 +19,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" @@ -41,6 +42,7 @@ func main() { 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) + rechargeAgencyService := rechargeagency.NewService(app.Config, app.Repository.DB, &app.Gateways) registerRewardService := registerreward.NewRegisterRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) rechargeRewardService := rechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways) signInRewardService := signinreward.NewSignInRewardService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways) @@ -67,6 +69,7 @@ func main() { GameProviders: gameProviders, LuckyGift: luckyGiftService, HostCenter: hostCenterService, + RechargeAgency: rechargeAgencyService, RechargeReward: rechargeRewardService, RegisterReward: registerRewardService, RoomTurnoverReward: roomTurnoverRewardService, diff --git a/internal/integration/gateways.go b/internal/integration/gateways.go index d5065b9..53fa165 100644 --- a/internal/integration/gateways.go +++ b/internal/integration/gateways.go @@ -19,6 +19,7 @@ type Gateways struct { Room *RoomGateway Team *TeamGateway Wallet *WalletGateway + Freight *FreightGateway } // NewGateways 基于共享 HTTP 客户端创建所有 Java 网关。 @@ -36,6 +37,7 @@ func NewGateways(cfg config.Config) Gateways { Room: &RoomGateway{client: client}, Team: &TeamGateway{client: client}, Wallet: &WalletGateway{client: client}, + Freight: &FreightGateway{client: client}, } } @@ -184,6 +186,11 @@ func (g *Gateways) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand return g.Wallet.ChangeGoldBalance(ctx, cmd) } +// PageFreightSellers 透传到 H5 币商列表接口。 +func (g *Gateways) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) { + return g.Freight.PageFreightSellers(ctx, authorization, sysOrigin, cursor, limit) +} + // AuthGateway 负责认证相关接口。 type AuthGateway struct { client *Client @@ -383,3 +390,13 @@ func (g *WalletGateway) ExistsGoldEvent(ctx context.Context, eventID string) (bo func (g *WalletGateway) ChangeGoldBalance(ctx context.Context, cmd GoldReceiptCommand) error { return g.client.ChangeGoldBalance(ctx, cmd) } + +// FreightGateway 负责货运代理/币商相关接口。 +type FreightGateway struct { + client *Client +} + +// PageFreightSellers 查询 H5 可见币商列表。 +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) +} diff --git a/internal/integration/java.go b/internal/integration/java.go index 457eb9d..5a68c8a 100644 --- a/internal/integration/java.go +++ b/internal/integration/java.go @@ -2,10 +2,10 @@ package integration import ( "bytes" - "chatapp3-golang/internal/config" "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -13,6 +13,8 @@ import ( "strconv" "strings" "time" + + "chatapp3-golang/internal/config" ) type Client struct { @@ -213,6 +215,57 @@ type UserTotalRecharge struct { UpdateTime string `json:"updateTime"` } +type FreightSellerPage struct { + Records []FreightSellerRecord `json:"records"` + Total int64 `json:"total"` + Current int `json:"current"` + Size int `json:"size"` +} + +type FreightSellerRecord struct { + ID Int64Value `json:"id"` + SysOrigin string `json:"sysOrigin"` + UserID Int64Value `json:"userId"` + EarnPoints DecimalString `json:"earnPoints"` + ConsumptionPoints DecimalString `json:"consumptionPoints"` + Balance DecimalString `json:"balance"` + Close bool `json:"close"` + Display bool `json:"display"` + Dealer bool `json:"dealer"` + SuperDealer bool `json:"superDealer"` + SupportedCountries string `json:"supportedCountries"` + ContactInfo string `json:"contactInfo"` + H5Display bool `json:"h5Display"` + SellerQuantity int `json:"sellerQuantity"` + RealSellerQuantity int64 `json:"realSellerQuantity"` + BecomeDays int `json:"becomeDays"` + TransactionCount int64 `json:"transactionCount"` + IsFollow bool `json:"isFollow"` + UserBaseInfo FreightSellerProfile `json:"userBaseInfo"` + NationalFlag []string `json:"nationalFlag"` + OwnNationalFlag string `json:"ownNationalFlag"` +} + +type FreightSellerProfile struct { + ID Int64Value `json:"id"` + Account string `json:"account"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + RegionCode string `json:"regionCode"` + OriginSys string `json:"originSys"` +} + +type freightSellerPageResponse struct { + Status *bool `json:"status"` + ErrorMsg string `json:"errorMsg"` + Message string `json:"message"` + Body *FreightSellerPage `json:"body"` + Data *FreightSellerPage `json:"data"` + FreightSellerPage +} + type resultResponse[T any] struct { Code int `json:"code"` Message string `json:"message"` @@ -577,6 +630,42 @@ func (c *Client) GetThisMonthTotalPersonalRecharge(ctx context.Context, userID i return resp.Body, nil } +func (c *Client) PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (FreightSellerPage, error) { + values := url.Values{} + values.Set("sysOrigin", strings.TrimSpace(sysOrigin)) + values.Set("dealer", "true") + values.Set("cursor", strconv.Itoa(cursor)) + values.Set("limit", strconv.Itoa(limit)) + + endpoint := strings.TrimRight(c.cfg.Java.OtherBaseURL, "/") + "/user/freight-balance/page?" + values.Encode() + headers := javaAppHeadersFromAuthorization(authorization) + if strings.TrimSpace(authorization) != "" { + headers.Set("Authorization", strings.TrimSpace(authorization)) + } + + var resp freightSellerPageResponse + if err := c.getJSON(ctx, endpoint, headers, &resp); err != nil { + return FreightSellerPage{}, err + } + if resp.Status != nil && !*resp.Status { + message := strings.TrimSpace(resp.ErrorMsg) + if message == "" { + message = strings.TrimSpace(resp.Message) + } + if message == "" { + message = "freight seller page failed" + } + return FreightSellerPage{}, errors.New(message) + } + if resp.Body != nil { + return *resp.Body, nil + } + if resp.Data != nil { + return *resp.Data, nil + } + return resp.FreightSellerPage, 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] diff --git a/internal/model/recharge_agency_models.go b/internal/model/recharge_agency_models.go new file mode 100644 index 0000000..b8255a7 --- /dev/null +++ b/internal/model/recharge_agency_models.go @@ -0,0 +1,17 @@ +package model + +import "time" + +// RechargeAgencySellerInfo stores H5-only profile fields for recharge agency sellers. +type RechargeAgencySellerInfo struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement"` + SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_recharge_agency_seller_info_user,priority:1"` + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_recharge_agency_seller_info_user,priority:2"` + Whatsapp string `gorm:"column:whatsapp;size:64"` + CreateTime time.Time `gorm:"column:create_time"` + UpdateTime time.Time `gorm:"column:update_time"` +} + +func (RechargeAgencySellerInfo) TableName() string { + return "recharge_agency_seller_info" +} diff --git a/internal/router/recharge_agency_routes.go b/internal/router/recharge_agency_routes.go new file mode 100644 index 0000000..faa896b --- /dev/null +++ b/internal/router/recharge_agency_routes.go @@ -0,0 +1,59 @@ +package router + +import ( + "net/http" + "strconv" + "strings" + + "chatapp3-golang/internal/service/rechargeagency" + + "github.com/gin-gonic/gin" +) + +// registerRechargeAgencyRoutes 注册充值代理 H5 币商名单路由。 +func registerRechargeAgencyRoutes(engine *gin.Engine, javaClient authGateway, service *rechargeagency.Service) { + if service == nil { + return + } + + handler := func(c *gin.Context) { + cursor, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("cursor", "1"))) + limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))) + + resp, err := service.PageSellers(c.Request.Context(), mustAuthUser(c), cursor, limit) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + } + + listGroup := engine.Group("/app/h5/recharge-agency-list") + listGroup.Use(authMiddleware(javaClient)) + listGroup.GET("/sellers", handler) + + agencyGroup := engine.Group("/app/h5/recharge-agency") + agencyGroup.Use(authMiddleware(javaClient)) + agencyGroup.GET("/sellers", handler) + agencyGroup.GET("/profile", func(c *gin.Context) { + resp, err := service.GetProfile(c.Request.Context(), mustAuthUser(c)) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) + agencyGroup.POST("/profile/whatsapp", func(c *gin.Context) { + var req rechargeagency.UpdateWhatsappRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, rechargeagency.NewAppError(http.StatusBadRequest, "bad_request", err.Error())) + return + } + resp, err := service.UpdateWhatsapp(c.Request.Context(), mustAuthUser(c), req) + if err != nil { + writeError(c, err) + return + } + writeOK(c, resp) + }) +} diff --git a/internal/router/recharge_agency_routes_test.go b/internal/router/recharge_agency_routes_test.go new file mode 100644 index 0000000..5d86a37 --- /dev/null +++ b/internal/router/recharge_agency_routes_test.go @@ -0,0 +1,161 @@ +package router + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + "chatapp3-golang/internal/service/rechargeagency" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type rechargeAgencyRouteAuth struct{} + +func (rechargeAgencyRouteAuth) AuthenticateToken(context.Context, string) (integration.UserCredential, error) { + return integration.UserCredential{ + UserID: integration.Int64Value(7), + SysOrigin: "LIKEI", + }, nil +} + +func (rechargeAgencyRouteAuth) AuthenticateConsoleToken(context.Context, string) (integration.ConsoleAccount, error) { + return integration.ConsoleAccount{}, nil +} + +type rechargeAgencyRouteJava struct { + authorization string + sysOrigin string +} + +func (f *rechargeAgencyRouteJava) PageFreightSellers(_ context.Context, authorization string, sysOrigin string, cursor int, limit int) (integration.FreightSellerPage, error) { + f.authorization = authorization + f.sysOrigin = sysOrigin + return integration.FreightSellerPage{ + Total: 1, + Current: cursor, + Size: limit, + Records: []integration.FreightSellerRecord{ + { + ID: integration.Int64Value(1), + SysOrigin: sysOrigin, + UserID: integration.Int64Value(99), + Balance: integration.DecimalString("10"), + UserBaseInfo: integration.FreightSellerProfile{ + Account: "u99", + UserNickname: "Nina", + }, + }, + }, + }, nil +} + +func TestRechargeAgencySellersRoute(t *testing.T) { + java := &rechargeAgencyRouteJava{} + 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.RechargeAgencySellerInfo{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + if err := db.Create(&model.RechargeAgencySellerInfo{ + SysOrigin: "LIKEI", + UserID: 99, + Whatsapp: "+8613800138000", + }).Error; err != nil { + t.Fatalf("seed whatsapp: %v", err) + } + + service := rechargeagency.NewService(config.Config{}, db, java) + engine := NewRouter(config.Config{}, nil, rechargeAgencyRouteAuth{}, Services{ + RechargeAgency: service, + }) + + req := httptest.NewRequest(http.MethodGet, "/app/h5/recharge-agency-list/sellers?cursor=2&limit=1", nil) + req.Header.Set("Authorization", "Bearer token") + rec := httptest.NewRecorder() + + engine.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if java.authorization != "Bearer token" || java.sysOrigin != "LIKEI" { + t.Fatalf("gateway authorization/sysOrigin = %q/%q", java.authorization, java.sysOrigin) + } + + var payload struct { + Body struct { + Total int64 `json:"total"` + Records []struct { + UserID string `json:"userId"` + UserNickname string `json:"userNickname"` + Whatsapp string `json:"whatsapp"` + } `json:"records"` + } `json:"body"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Body.Total != 1 || len(payload.Body.Records) != 1 { + t.Fatalf("payload body mismatch: %+v", payload.Body) + } + if payload.Body.Records[0].UserID != "99" || payload.Body.Records[0].UserNickname != "Nina" { + t.Fatalf("record mismatch: %+v", payload.Body.Records[0]) + } + if payload.Body.Records[0].Whatsapp != "+8613800138000" { + t.Fatalf("whatsapp mismatch: %+v", payload.Body.Records[0]) + } +} + +func TestRechargeAgencyProfileRoutes(t *testing.T) { + 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.RechargeAgencySellerInfo{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + service := rechargeagency.NewService(config.Config{}, db, nil) + engine := NewRouter(config.Config{}, nil, rechargeAgencyRouteAuth{}, Services{ + RechargeAgency: service, + }) + + postReq := httptest.NewRequest(http.MethodPost, "/app/h5/recharge-agency/profile/whatsapp", bytes.NewBufferString(`{"whatsapp":"+8613800138000"}`)) + postReq.Header.Set("Authorization", "Bearer token") + postReq.Header.Set("Content-Type", "application/json") + postRec := httptest.NewRecorder() + engine.ServeHTTP(postRec, postReq) + if postRec.Code != http.StatusOK { + t.Fatalf("post status = %d body = %s", postRec.Code, postRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/app/h5/recharge-agency/profile", nil) + getReq.Header.Set("Authorization", "Bearer token") + getRec := httptest.NewRecorder() + engine.ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("get status = %d body = %s", getRec.Code, getRec.Body.String()) + } + + var payload struct { + Body struct { + UserID string `json:"userId"` + Whatsapp string `json:"whatsapp"` + } `json:"body"` + } + if err := json.Unmarshal(getRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode get response: %v", err) + } + if payload.Body.UserID != "7" || payload.Body.Whatsapp != "+8613800138000" { + t.Fatalf("profile body mismatch: %+v", payload.Body) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 046186a..7ece879 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,7 @@ import ( "chatapp3-golang/internal/service/invite" "chatapp3-golang/internal/service/lingxian" "chatapp3-golang/internal/service/luckygift" + "chatapp3-golang/internal/service/rechargeagency" "chatapp3-golang/internal/service/rechargereward" "chatapp3-golang/internal/service/registerreward" "chatapp3-golang/internal/service/roomturnoverreward" @@ -32,6 +33,7 @@ type Services struct { GameProviders *gameprovider.Registry LuckyGift *luckygift.LuckyGiftService HostCenter *hostcenter.Service + RechargeAgency *rechargeagency.Service RechargeReward *rechargereward.Service RegisterReward *registerreward.RegisterRewardService RoomTurnoverReward *roomturnoverreward.Service @@ -52,6 +54,7 @@ func NewRouter( registerHealthRoutes(engine, cfg, repository) registerInviteRoutes(engine, javaClient, services.Invite) + registerRechargeAgencyRoutes(engine, javaClient, services.RechargeAgency) registerRechargeRewardRoutes(engine, javaClient, services.RechargeReward) registerRegisterRewardRoutes(engine, javaClient, services.RegisterReward) registerSignInRewardRoutes(engine, javaClient, services.SignInReward) diff --git a/internal/service/rechargeagency/list.go b/internal/service/rechargeagency/list.go new file mode 100644 index 0000000..55401ff --- /dev/null +++ b/internal/service/rechargeagency/list.go @@ -0,0 +1,118 @@ +package rechargeagency + +import ( + "context" + "net/http" + "strings" + + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" +) + +// PageSellers 返回当前用户可见的 H5 币商名单。 +func (s *Service) PageSellers(ctx context.Context, user AuthUser, cursor int, limit int) (*SellerListResponse, error) { + if s.java == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "java_gateway_unavailable", "java gateway is unavailable") + } + if strings.TrimSpace(user.Authorization) == "" { + return nil, NewAppError(http.StatusUnauthorized, "missing_authorization", "authorization is required") + } + + sysOrigin := normalizeSysOrigin(user.SysOrigin) + if sysOrigin == "" { + return nil, NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required") + } + cursor, limit = normalizePage(cursor, limit) + + page, err := s.java.PageFreightSellers(ctx, user.Authorization, sysOrigin, cursor, limit) + if err != nil { + return nil, NewAppError(http.StatusBadGateway, "freight_seller_list_failed", err.Error()) + } + + records := make([]SellerView, 0, len(page.Records)) + for _, record := range page.Records { + records = append(records, buildSellerView(record)) + } + if s.db != nil { + whatsapps, err := s.loadSellerWhatsapps(ctx, sysOrigin, collectSellerUserIDs(records)) + if err != nil { + return nil, err + } + for i := range records { + if whatsapp := strings.TrimSpace(whatsapps[records[i].UserID]); whatsapp != "" { + records[i].Whatsapp = whatsapp + } + } + } + + return &SellerListResponse{ + SysOrigin: sysOrigin, + Cursor: cursor, + Limit: limit, + Total: page.Total, + Records: records, + }, nil +} + +func buildSellerView(record integration.FreightSellerRecord) SellerView { + profile := record.UserBaseInfo + return SellerView{ + ID: int64(record.ID), + SysOrigin: strings.TrimSpace(record.SysOrigin), + UserID: int64(record.UserID), + Account: firstNonBlank(profile.Account, formatInt64(int64(record.UserID))), + UserAvatar: strings.TrimSpace(profile.UserAvatar), + UserNickname: firstNonBlank(profile.UserNickname, profile.Account, formatInt64(int64(record.UserID))), + CountryCode: strings.TrimSpace(profile.CountryCode), + CountryName: strings.TrimSpace(profile.CountryName), + RegionCode: strings.TrimSpace(profile.RegionCode), + Balance: normalizeDecimalString(string(record.Balance)), + ContactInfo: strings.TrimSpace(record.ContactInfo), + SupportedCountries: strings.TrimSpace(record.SupportedCountries), + NationalFlag: normalizeFlags(record.NationalFlag), + OwnNationalFlag: strings.TrimSpace(record.OwnNationalFlag), + BecomeDays: record.BecomeDays, + TransactionCount: record.TransactionCount, + IsFollow: record.IsFollow, + SuperDealer: record.SuperDealer, + } +} + +func collectSellerUserIDs(records []SellerView) []int64 { + seen := make(map[int64]struct{}, len(records)) + userIDs := make([]int64, 0, len(records)) + for _, record := range records { + if record.UserID <= 0 { + continue + } + if _, ok := seen[record.UserID]; ok { + continue + } + seen[record.UserID] = struct{}{} + userIDs = append(userIDs, record.UserID) + } + return userIDs +} + +func (s *Service) loadSellerWhatsapps(ctx context.Context, sysOrigin string, userIDs []int64) (map[int64]string, error) { + whatsapps := make(map[int64]string, len(userIDs)) + if len(userIDs) == 0 { + return whatsapps, nil + } + + var rows []model.RechargeAgencySellerInfo + err := s.db.WithContext(ctx). + Select("user_id", "whatsapp"). + Where("sys_origin = ? AND user_id IN ?", sysOrigin, userIDs). + Find(&rows).Error + if err != nil { + return nil, NewAppError(http.StatusInternalServerError, "seller_whatsapp_query_failed", err.Error()) + } + + for _, row := range rows { + if whatsapp := strings.TrimSpace(row.Whatsapp); whatsapp != "" { + whatsapps[row.UserID] = whatsapp + } + } + return whatsapps, nil +} diff --git a/internal/service/rechargeagency/list_test.go b/internal/service/rechargeagency/list_test.go new file mode 100644 index 0000000..038e0da --- /dev/null +++ b/internal/service/rechargeagency/list_test.go @@ -0,0 +1,119 @@ +package rechargeagency + +import ( + "context" + "testing" + "time" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type fakeJavaGateway struct { + gotAuthorization string + gotSysOrigin string + gotCursor int + gotLimit int + page integration.FreightSellerPage +} + +func (f *fakeJavaGateway) PageFreightSellers(_ context.Context, authorization string, sysOrigin string, cursor int, limit int) (integration.FreightSellerPage, error) { + f.gotAuthorization = authorization + f.gotSysOrigin = sysOrigin + f.gotCursor = cursor + f.gotLimit = limit + return f.page, nil +} + +func TestPageSellersUsesAuthenticatedUserAndMapsRecords(t *testing.T) { + 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.RechargeAgencySellerInfo{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + if err := db.Create(&model.RechargeAgencySellerInfo{ + SysOrigin: "LIKEI", + UserID: 99, + Whatsapp: "+8613800138000", + CreateTime: time.Now(), + UpdateTime: time.Now(), + }).Error; err != nil { + t.Fatalf("seed whatsapp: %v", err) + } + + java := &fakeJavaGateway{ + page: integration.FreightSellerPage{ + Total: 1, + Current: 1, + Size: 20, + Records: []integration.FreightSellerRecord{ + { + ID: integration.Int64Value(11), + SysOrigin: "LIKEI", + UserID: integration.Int64Value(99), + Balance: integration.DecimalString("123.45"), + SuperDealer: true, + NationalFlag: []string{"", "https://flag.example/ae.png"}, + OwnNationalFlag: "https://flag.example/own.png", + BecomeDays: 7, + TransactionCount: 12, + UserBaseInfo: integration.FreightSellerProfile{ + Account: "u99", + UserNickname: "Nina", + UserAvatar: "https://avatar.example/u99.png", + CountryCode: "AE", + CountryName: "United Arab Emirates", + RegionCode: "MENA", + }, + }, + }, + }, + } + service := NewService(config.Config{}, db, java) + + resp, err := service.PageSellers(context.Background(), AuthUser{ + SysOrigin: "likei", + Authorization: "Bearer token", + }, 0, 200) + if err != nil { + t.Fatalf("PageSellers() error = %v", err) + } + + if java.gotAuthorization != "Bearer token" || java.gotSysOrigin != "LIKEI" { + t.Fatalf("gateway auth/sysOrigin = %q/%q", java.gotAuthorization, java.gotSysOrigin) + } + if java.gotCursor != 1 || java.gotLimit != 100 { + t.Fatalf("gateway cursor/limit = %d/%d", java.gotCursor, java.gotLimit) + } + if resp.Total != 1 || len(resp.Records) != 1 { + t.Fatalf("response total/records = %d/%d", resp.Total, len(resp.Records)) + } + record := resp.Records[0] + if record.ID != 11 || record.UserID != 99 || record.Account != "u99" || record.UserNickname != "Nina" { + t.Fatalf("mapped record identity mismatch: %+v", record) + } + if record.Balance != "123.45" || !record.SuperDealer || record.TransactionCount != 12 { + t.Fatalf("mapped record stats mismatch: %+v", record) + } + if record.Whatsapp != "+8613800138000" { + t.Fatalf("mapped whatsapp = %q", record.Whatsapp) + } + if len(record.NationalFlag) != 1 || record.NationalFlag[0] != "https://flag.example/ae.png" { + t.Fatalf("mapped flags mismatch: %+v", record.NationalFlag) + } +} + +func TestPageSellersRequiresAuthorization(t *testing.T) { + service := NewService(config.Config{}, nil, &fakeJavaGateway{}) + + _, err := service.PageSellers(context.Background(), AuthUser{SysOrigin: "LIKEI"}, 1, 20) + if err == nil { + t.Fatal("PageSellers() error is nil") + } +} diff --git a/internal/service/rechargeagency/normalize.go b/internal/service/rechargeagency/normalize.go new file mode 100644 index 0000000..a353b7e --- /dev/null +++ b/internal/service/rechargeagency/normalize.go @@ -0,0 +1,60 @@ +package rechargeagency + +import ( + "strconv" + "strings" +) + +func normalizeSysOrigin(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizePage(cursor int, limit int) (int, int) { + if cursor <= 0 { + cursor = 1 + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return cursor, limit +} + +func normalizeDecimalString(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "0" + } + return normalized +} + +func normalizeFlags(flags []string) []string { + if len(flags) == 0 { + return []string{} + } + result := make([]string, 0, len(flags)) + for _, flag := range flags { + if normalized := strings.TrimSpace(flag); normalized != "" { + result = append(result, normalized) + } + } + return result +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if normalized := strings.TrimSpace(value); normalized != "" { + return normalized + } + } + return "" +} + +func formatInt64(value int64) string { + if value <= 0 { + return "" + } + return strconv.FormatInt(value, 10) +} diff --git a/internal/service/rechargeagency/profile.go b/internal/service/rechargeagency/profile.go new file mode 100644 index 0000000..e083074 --- /dev/null +++ b/internal/service/rechargeagency/profile.go @@ -0,0 +1,101 @@ +package rechargeagency + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "chatapp3-golang/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GetProfile returns the current recharge agency seller extension profile. +func (s *Service) GetProfile(ctx context.Context, user AuthUser) (*SellerProfileResponse, error) { + sysOrigin, userID, err := normalizeSellerProfileUser(user) + if err != nil { + return nil, err + } + if s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable") + } + + var row model.RechargeAgencySellerInfo + err = s.db.WithContext(ctx). + Where("sys_origin = ? AND user_id = ?", sysOrigin, userID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &SellerProfileResponse{ + SysOrigin: sysOrigin, + UserID: userID, + Whatsapp: "", + }, nil + } + if err != nil { + return nil, NewAppError(http.StatusInternalServerError, "seller_profile_query_failed", err.Error()) + } + + return &SellerProfileResponse{ + SysOrigin: sysOrigin, + UserID: userID, + Whatsapp: strings.TrimSpace(row.Whatsapp), + }, nil +} + +// UpdateWhatsapp stores the current recharge agency seller WhatsApp contact. +func (s *Service) UpdateWhatsapp(ctx context.Context, user AuthUser, req UpdateWhatsappRequest) (*SellerProfileResponse, error) { + sysOrigin, userID, err := normalizeSellerProfileUser(user) + if err != nil { + return nil, err + } + if s.db == nil { + return nil, NewAppError(http.StatusServiceUnavailable, "database_unavailable", "database is unavailable") + } + + whatsapp := strings.TrimSpace(req.Whatsapp) + if whatsapp == "" { + return nil, NewAppError(http.StatusBadRequest, "whatsapp_required", "whatsapp is required") + } + if len([]rune(whatsapp)) > 64 { + return nil, NewAppError(http.StatusBadRequest, "whatsapp_too_long", "whatsapp cannot exceed 64 characters") + } + + now := time.Now() + row := model.RechargeAgencySellerInfo{ + SysOrigin: sysOrigin, + UserID: userID, + Whatsapp: whatsapp, + CreateTime: now, + UpdateTime: now, + } + err = s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "sys_origin"}, {Name: "user_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "whatsapp": whatsapp, + "update_time": now, + }), + }).Create(&row).Error + if err != nil { + return nil, NewAppError(http.StatusInternalServerError, "seller_profile_save_failed", err.Error()) + } + + return &SellerProfileResponse{ + SysOrigin: sysOrigin, + UserID: userID, + Whatsapp: whatsapp, + }, nil +} + +func normalizeSellerProfileUser(user AuthUser) (string, int64, error) { + sysOrigin := normalizeSysOrigin(user.SysOrigin) + if sysOrigin == "" { + return "", 0, NewAppError(http.StatusUnauthorized, "invalid_sys_origin", "authenticated sysOrigin is required") + } + if user.UserID <= 0 { + return "", 0, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") + } + return sysOrigin, user.UserID, nil +} diff --git a/internal/service/rechargeagency/profile_test.go b/internal/service/rechargeagency/profile_test.go new file mode 100644 index 0000000..5e7fd13 --- /dev/null +++ b/internal/service/rechargeagency/profile_test.go @@ -0,0 +1,80 @@ +package rechargeagency + +import ( + "context" + "strings" + "testing" + + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newRechargeAgencyProfileTestService(t *testing.T) *Service { + 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.RechargeAgencySellerInfo{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return NewService(config.Config{}, db, nil) +} + +func TestSellerProfileWhatsappUpsert(t *testing.T) { + service := newRechargeAgencyProfileTestService(t) + user := AuthUser{ + UserID: 2042274349343506434, + SysOrigin: "likei", + } + + empty, err := service.GetProfile(context.Background(), user) + if err != nil { + t.Fatalf("GetProfile() error = %v", err) + } + if empty.SysOrigin != "LIKEI" || empty.UserID != user.UserID || empty.Whatsapp != "" { + t.Fatalf("empty profile = %+v", empty) + } + + saved, err := service.UpdateWhatsapp(context.Background(), user, UpdateWhatsappRequest{Whatsapp: " +8613800138000 "}) + if err != nil { + t.Fatalf("UpdateWhatsapp() error = %v", err) + } + if saved.Whatsapp != "+8613800138000" { + t.Fatalf("saved whatsapp = %q", saved.Whatsapp) + } + + updated, err := service.UpdateWhatsapp(context.Background(), user, UpdateWhatsappRequest{Whatsapp: "+8613900139000"}) + if err != nil { + t.Fatalf("second UpdateWhatsapp() error = %v", err) + } + if updated.Whatsapp != "+8613900139000" { + t.Fatalf("updated whatsapp = %q", updated.Whatsapp) + } + + got, err := service.GetProfile(context.Background(), user) + if err != nil { + t.Fatalf("GetProfile() after save error = %v", err) + } + if got.Whatsapp != "+8613900139000" { + t.Fatalf("got whatsapp = %q", got.Whatsapp) + } +} + +func TestUpdateWhatsappValidatesInput(t *testing.T) { + service := newRechargeAgencyProfileTestService(t) + user := AuthUser{ + UserID: 7, + SysOrigin: "LIKEI", + } + + if _, err := service.UpdateWhatsapp(context.Background(), user, UpdateWhatsappRequest{}); err == nil { + t.Fatal("UpdateWhatsapp() empty error is nil") + } + if _, err := service.UpdateWhatsapp(context.Background(), user, UpdateWhatsappRequest{Whatsapp: strings.Repeat("1", 65)}); err == nil { + t.Fatal("UpdateWhatsapp() long error is nil") + } +} diff --git a/internal/service/rechargeagency/types.go b/internal/service/rechargeagency/types.go new file mode 100644 index 0000000..577de11 --- /dev/null +++ b/internal/service/rechargeagency/types.go @@ -0,0 +1,75 @@ +package rechargeagency + +import ( + "context" + + "chatapp3-golang/internal/common" + "chatapp3-golang/internal/config" + "chatapp3-golang/internal/integration" + + "gorm.io/gorm" +) + +type AppError = common.AppError +type AuthUser = common.AuthUser + +var NewAppError = common.NewAppError + +type javaGateway interface { + PageFreightSellers(ctx context.Context, authorization string, sysOrigin string, cursor int, limit int) (integration.FreightSellerPage, error) +} + +type rechargeAgencyDB interface { + WithContext(ctx context.Context) *gorm.DB +} + +// Service 负责充值代理 H5 币商名单数据。 +type Service struct { + cfg config.Config + db rechargeAgencyDB + java javaGateway +} + +func NewService(cfg config.Config, db rechargeAgencyDB, java javaGateway) *Service { + return &Service{cfg: cfg, db: db, java: java} +} + +type SellerListResponse struct { + SysOrigin string `json:"sysOrigin"` + Cursor int `json:"cursor"` + Limit int `json:"limit"` + Total int64 `json:"total"` + Records []SellerView `json:"records"` +} + +type SellerView struct { + ID int64 `json:"id,string"` + SysOrigin string `json:"sysOrigin"` + UserID int64 `json:"userId,string"` + Account string `json:"account"` + UserAvatar string `json:"userAvatar"` + UserNickname string `json:"userNickname"` + CountryCode string `json:"countryCode"` + CountryName string `json:"countryName"` + RegionCode string `json:"regionCode"` + Balance string `json:"balance"` + ContactInfo string `json:"contactInfo,omitempty"` + Whatsapp string `json:"whatsapp,omitempty"` + SupportedCountries string `json:"supportedCountries,omitempty"` + NationalFlag []string `json:"nationalFlag"` + OwnNationalFlag string `json:"ownNationalFlag,omitempty"` + BecomeDays int `json:"becomeDays"` + TransactionCount int64 `json:"transactionCount"` + IsFollow bool `json:"isFollow"` + SuperDealer bool `json:"superDealer"` +} + +type SellerProfileResponse struct { + SysOrigin string `json:"sysOrigin"` + UserID int64 `json:"userId,string"` + Whatsapp string `json:"whatsapp"` +} + +type UpdateWhatsappRequest struct { + Whatsapp string `json:"whatsapp"` +} diff --git a/migrations/021_recharge_agency_seller_info.sql b/migrations/021_recharge_agency_seller_info.sql new file mode 100644 index 0000000..4f46e97 --- /dev/null +++ b/migrations/021_recharge_agency_seller_info.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `recharge_agency_seller_info` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `sys_origin` varchar(32) NOT NULL COMMENT '来源系统', + `user_id` bigint NOT NULL COMMENT '币商用户ID', + `whatsapp` varchar(64) NOT NULL DEFAULT '' COMMENT 'WhatsApp', + `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间', + `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_recharge_agency_seller_info_user` (`sys_origin`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值代理币商扩展资料';