From 65b0558e03893a583e7c7557522737068ea0d52c Mon Sep 17 00:00:00 2001 From: hy001 Date: Wed, 29 Apr 2026 19:29:12 +0800 Subject: [PATCH] vip --- internal/service/lingxian/catalog.go | 23 +++- internal/service/lingxian/catalog_test.go | 158 ++++++++++++++++++++++ internal/service/vip/app.go | 13 +- internal/service/vip/config.go | 10 ++ internal/service/vip/purchase_test.go | 37 ++++- internal/service/vip/types.go | 61 ++++----- 6 files changed, 264 insertions(+), 38 deletions(-) diff --git a/internal/service/lingxian/catalog.go b/internal/service/lingxian/catalog.go index e3399ac..07536e0 100644 --- a/internal/service/lingxian/catalog.go +++ b/internal/service/lingxian/catalog.go @@ -270,6 +270,7 @@ func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, f continue } now := time.Now() + fullScreen := catalogFullScreen(catalog) var ext model.SysGameListVendorExt err := tx.Where("sys_origin = ? AND profile = ? AND vendor_type = ? AND vendor_game_id = ?", sysOrigin, profile, vendorType, vendorGameID).First(&ext).Error if err != nil && err != gorm.ErrRecordNotFound { @@ -279,6 +280,13 @@ func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, f var cfg model.SysGameListConfig cfgErr := tx.Where("id = ? AND sys_origin = ? AND game_origin = ?", ext.GameListConfigID, sysOrigin, vendorType).First(&cfg).Error if cfgErr == nil { + if cfg.FullScreen != fullScreen { + if err := tx.Model(&model.SysGameListConfig{}). + Where("id = ?", cfg.ID). + Update("full_screen", fullScreen).Error; err != nil { + return nil, err + } + } resp.Existing++ continue } @@ -302,7 +310,7 @@ func (s *Service) importCatalogGamesTx(tx *gorm.DB, sysOrigin, profile string, f Cover: defaultIfBlank(catalog.Cover, catalog.PreviewURL), Showcase: defaultShowcase, Sort: parseSort(vendorGameID), - FullScreen: true, + FullScreen: fullScreen, ClientOrigin: "COMMON", GameMode: "[3]", } @@ -387,6 +395,19 @@ func parseSort(value string) int64 { return sortValue } +func catalogFullScreen(catalog model.LingxianGameCatalog) bool { + type rawCatalog struct { + FullURL string `json:"full_url"` + HalfURL string `json:"half_url"` + } + var raw rawCatalog + _ = json.Unmarshal([]byte(strings.TrimSpace(catalog.RawJSON)), &raw) + if strings.TrimSpace(raw.FullURL) == "" && strings.TrimSpace(raw.HalfURL) != "" { + return false + } + return !strings.Contains(strings.ToLower(defaultIfBlank(catalog.PreviewURL, catalog.DownloadURL)), "_half") +} + func shortBody(raw []byte) string { body := strings.TrimSpace(string(raw)) const max = 512 diff --git a/internal/service/lingxian/catalog_test.go b/internal/service/lingxian/catalog_test.go index 5bdba5c..bada20e 100644 --- a/internal/service/lingxian/catalog_test.go +++ b/internal/service/lingxian/catalog_test.go @@ -138,6 +138,164 @@ func TestImportCatalogGamesCanDefaultToHidden(t *testing.T) { } } +func TestImportCatalogGamesMarksHalfScreenFromCatalog(t *testing.T) { + ctx := context.Background() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.LingxianProviderConfig{}, + &model.LingxianGameCatalog{}, + &model.SysGameListConfig{}, + &model.SysGameListVendorExt{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Now() + if err := db.Create(&model.LingxianProviderConfig{ + ID: 31, + SysOrigin: "LIKEI", + Profile: profileProd, + Active: true, + AppKey: "test-key", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed provider: %v", err) + } + if err := db.Create(&model.LingxianGameCatalog{ + ID: 32, + SysOrigin: "LIKEI", + Profile: profileProd, + InternalGameID: "1", + VendorGameID: "1", + Name: "Half Lingxian", + PreviewURL: "https://example.test/fruit_party_half/index.html", + DownloadURL: "https://example.test/fruit_party_half/index.html", + RawJSON: `{"gameId":"1","half_url":"https://example.test/fruit_party_half/index.html","full_url":""}`, + Status: catalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed catalog: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + resp, err := service.ImportCatalogGames(ctx, "LIKEI", profileProd, []string{"1"}) + if err != nil { + t.Fatalf("ImportCatalogGames: %v", err) + } + if resp.Imported != 1 { + t.Fatalf("imported = %d, want 1", resp.Imported) + } + + var cfg model.SysGameListConfig + if err := db.Where("sys_origin = ? AND game_origin = ? AND game_id = ?", "LIKEI", vendorType, "1").First(&cfg).Error; err != nil { + t.Fatalf("unified config missing: %v", err) + } + if cfg.FullScreen { + t.Fatalf("fullScreen = true, want false") + } +} + +func TestImportCatalogGamesRepairsExistingHalfScreenConfig(t *testing.T) { + ctx := context.Background() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.LingxianProviderConfig{}, + &model.LingxianGameCatalog{}, + &model.SysGameListConfig{}, + &model.SysGameListVendorExt{}, + ); err != nil { + t.Fatalf("migrate: %v", err) + } + + now := time.Now() + if err := db.Create(&model.LingxianProviderConfig{ + ID: 41, + SysOrigin: "LIKEI", + Profile: profileProd, + Active: true, + AppKey: "test-key", + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed provider: %v", err) + } + if err := db.Create(&model.LingxianGameCatalog{ + ID: 42, + SysOrigin: "LIKEI", + Profile: profileProd, + InternalGameID: "1", + VendorGameID: "1", + Name: "Half Lingxian", + PreviewURL: "https://example.test/fruit_party_half/index.html", + DownloadURL: "https://example.test/fruit_party_half/index.html", + RawJSON: `{"gameId":"1","half_url":"https://example.test/fruit_party_half/index.html","full_url":""}`, + Status: catalogEnabled, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed catalog: %v", err) + } + if err := db.Create(&model.SysGameListConfig{ + ID: 43, + SysOrigin: "LIKEI", + GameOrigin: vendorType, + GameID: "1", + Name: "Half Lingxian", + Category: roomCategory, + GameCode: "1", + Showcase: true, + Sort: 1, + FullScreen: true, + ClientOrigin: "COMMON", + GameMode: "[3]", + }).Error; err != nil { + t.Fatalf("seed config: %v", err) + } + if err := db.Create(&model.SysGameListVendorExt{ + ID: 44, + GameListConfigID: 43, + SysOrigin: "LIKEI", + Profile: profileProd, + VendorType: vendorType, + VendorGameID: "1", + LaunchMode: launchModeH5, + PackageURL: "https://example.test/fruit_party_half/index.html", + PreviewURL: "https://example.test/fruit_party_half/index.html", + BridgeSchemaVersion: "1.0", + ExtraJSON: `{"gameType":"LINGXIAN"}`, + Enabled: true, + CreateTime: now, + UpdateTime: now, + }).Error; err != nil { + t.Fatalf("seed vendor ext: %v", err) + } + + service := NewService(config.Config{HTTP: config.HTTPConfig{Timeout: time.Second}}, db, nil) + resp, err := service.ImportCatalogGames(ctx, "LIKEI", profileProd, []string{"1"}) + if err != nil { + t.Fatalf("ImportCatalogGames: %v", err) + } + if resp.Existing != 1 { + t.Fatalf("existing = %d, want 1", resp.Existing) + } + + var cfg model.SysGameListConfig + if err := db.First(&cfg, 43).Error; err != nil { + t.Fatalf("config missing: %v", err) + } + if cfg.FullScreen { + t.Fatalf("fullScreen = true, want false after repair") + } +} + func TestSaveProviderConfigPersistsTestRoomID(t *testing.T) { ctx := context.Background() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/service/vip/app.go b/internal/service/vip/app.go index 088d724..4e65811 100644 --- a/internal/service/vip/app.go +++ b/internal/service/vip/app.go @@ -241,7 +241,7 @@ func (s *Service) Purchase(ctx context.Context, user AuthUser, req PurchaseReque return nil, err } if err := s.java.ChangeGoldBalance(ctx, buildWalletCommand(user.UserID, sysOrigin, order)); err != nil { - appErr := NewAppError(http.StatusBadGateway, "vip_wallet_deduct_failed", err.Error()) + appErr := mapWalletPurchaseError(err) if saveErr := s.markOrderFailed(tx, &order, err.Error()); saveErr != nil { return nil, saveErr } @@ -487,6 +487,17 @@ func expireState(state *model.UserVipState, now time.Time) { state.UpdateTime = now } +func mapWalletPurchaseError(err error) *AppError { + message := err.Error() + normalized := strings.ToLower(message) + if strings.Contains(normalized, "insufficient_balance") || + strings.Contains(normalized, "balance not made") || + strings.Contains(normalized, `"errorcode":5000`) { + return NewAppError(http.StatusNotAcceptable, "vip_insufficient_balance", "insufficient balance") + } + return NewAppError(http.StatusBadGateway, "vip_wallet_deduct_failed", message) +} + func buildWalletCommand(userID int64, sysOrigin string, order model.VipOrderRecord) integration.GoldReceiptCommand { return integration.GoldReceiptCommand{ ReceiptType: walletReceiptExpenditure, diff --git a/internal/service/vip/config.go b/internal/service/vip/config.go index 59a3ee5..0fb9e99 100644 --- a/internal/service/vip/config.go +++ b/internal/service/vip/config.go @@ -136,6 +136,16 @@ func normalizeResource(resource VipResourcePayload) VipResourcePayload { } resource.Name = strings.TrimSpace(resource.Name) resource.URL = strings.TrimSpace(resource.URL) + resource.SourceURL = strings.TrimSpace(resource.SourceURL) + resource.ResourceURL = strings.TrimSpace(resource.ResourceURL) + if resource.URL == "" { + resource.URL = resource.SourceURL + } + if resource.URL == "" { + resource.URL = resource.ResourceURL + } + resource.SourceURL = resource.URL + resource.ResourceURL = resource.URL return resource } diff --git a/internal/service/vip/purchase_test.go b/internal/service/vip/purchase_test.go index 09be14c..91b3370 100644 --- a/internal/service/vip/purchase_test.go +++ b/internal/service/vip/purchase_test.go @@ -2,6 +2,8 @@ package vip import ( "encoding/json" + "errors" + "net/http" "testing" "time" @@ -104,22 +106,53 @@ func TestBuildPurchasePlanRejectsDowngrade(t *testing.T) { func TestVipResourcePayloadAcceptsStringResourceID(t *testing.T) { var payload VipResourcePayload - if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga"}`), &payload); err != nil { + if err := json.Unmarshal([]byte(`{"resourceId":"2045029471274201089","name":"vip","sourceUrl":"https://example.com/a.svga"}`), &payload); err != nil { t.Fatalf("unmarshal resource payload: %v", err) } + payload = normalizeResource(payload) if payload.ResourceID.Int64() != 2045029471274201089 { t.Fatalf("resource id = %d, want 2045029471274201089", payload.ResourceID.Int64()) } + if payload.URL != "https://example.com/a.svga" || payload.SourceURL != payload.URL || payload.ResourceURL != payload.URL { + t.Fatalf("resource urls = url:%q sourceUrl:%q resourceUrl:%q", payload.URL, payload.SourceURL, payload.ResourceURL) + } body, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal resource payload: %v", err) } - if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga"}` { + if got := string(body); got != `{"resourceId":"2045029471274201089","name":"vip","url":"https://example.com/a.svga","sourceUrl":"https://example.com/a.svga","resourceUrl":"https://example.com/a.svga"}` { t.Fatalf("json = %s", got) } } +func TestMapWalletPurchaseErrorInsufficientBalance(t *testing.T) { + err := errors.New(`java api failed: status=406 body={"errorCode":5000,"errorCodeName":"INSUFFICIENT_BALANCE","errorMsg":"balance not made"}`) + + appErr := mapWalletPurchaseError(err) + if appErr.Status != http.StatusNotAcceptable { + t.Fatalf("status = %d, want %d", appErr.Status, http.StatusNotAcceptable) + } + if appErr.Code != "vip_insufficient_balance" { + t.Fatalf("code = %s, want vip_insufficient_balance", appErr.Code) + } + if appErr.Message != "insufficient balance" { + t.Fatalf("message = %s, want insufficient balance", appErr.Message) + } +} + +func TestMapWalletPurchaseErrorGatewayFailure(t *testing.T) { + err := errors.New("java api failed: status=500 body=boom") + + appErr := mapWalletPurchaseError(err) + if appErr.Status != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", appErr.Status, http.StatusBadGateway) + } + if appErr.Code != "vip_wallet_deduct_failed" { + t.Fatalf("code = %s, want vip_wallet_deduct_failed", appErr.Code) + } +} + func vipTestConfigs() map[int]model.VipLevelConfig { configs := make(map[int]model.VipLevelConfig, levelCount) for level := 1; level <= levelCount; level++ { diff --git a/internal/service/vip/types.go b/internal/service/vip/types.go index ff2c18e..13f769c 100644 --- a/internal/service/vip/types.go +++ b/internal/service/vip/types.go @@ -88,9 +88,11 @@ type VipLevelPayload struct { } type VipResourcePayload struct { - ResourceID ResourceID `json:"resourceId"` - Name string `json:"name"` - URL string `json:"url"` + ResourceID ResourceID `json:"resourceId"` + Name string `json:"name"` + URL string `json:"url"` + SourceURL string `json:"sourceUrl,omitempty"` + ResourceURL string `json:"resourceUrl,omitempty"` } type ResourceID int64 @@ -293,6 +295,17 @@ func defaultLevelPayload(level int) VipLevelPayload { } } +func resourcePayload(resourceID int64, name string, url string) VipResourcePayload { + url = strings.TrimSpace(url) + return VipResourcePayload{ + ResourceID: ResourceID(resourceID), + Name: strings.TrimSpace(name), + URL: url, + SourceURL: url, + ResourceURL: url, + } +} + func payloadFromConfig(row model.VipLevelConfig) VipLevelPayload { duration := row.DurationDays if duration <= 0 { @@ -314,32 +327,12 @@ func payloadFromConfig(row model.VipLevelConfig) VipLevelPayload { Enabled: row.Enabled, DurationDays: duration, PriceGold: row.PriceGold, - Badge: VipResourcePayload{ - ResourceID: ResourceID(row.BadgeResourceID), - Name: row.BadgeName, - URL: row.BadgeURL, - }, - AvatarFrame: VipResourcePayload{ - ResourceID: ResourceID(row.AvatarFrameResourceID), - Name: row.AvatarFrameName, - URL: row.AvatarFrameURL, - }, - EntryEffect: VipResourcePayload{ - ResourceID: ResourceID(row.EntryEffectResourceID), - Name: row.EntryEffectName, - URL: row.EntryEffectURL, - }, - ChatBubble: VipResourcePayload{ - ResourceID: ResourceID(row.ChatBubbleResourceID), - Name: row.ChatBubbleName, - URL: row.ChatBubbleURL, - }, - FloatPicture: VipResourcePayload{ - ResourceID: ResourceID(row.FloatPictureResourceID), - Name: row.FloatPictureName, - URL: row.FloatPictureURL, - }, - UpdateTime: formatTime(row.UpdateTime), + Badge: resourcePayload(row.BadgeResourceID, row.BadgeName, row.BadgeURL), + AvatarFrame: resourcePayload(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL), + EntryEffect: resourcePayload(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL), + ChatBubble: resourcePayload(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL), + FloatPicture: resourcePayload(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL), + UpdateTime: formatTime(row.UpdateTime), } } @@ -368,11 +361,11 @@ func statePayloadFromModel(row *model.UserVipState, now time.Time) UserVipStateP payload.DisplayName = "" return payload } - payload.Badge = VipResourcePayload{ResourceID: ResourceID(row.BadgeResourceID), Name: row.BadgeName, URL: row.BadgeURL} - payload.AvatarFrame = VipResourcePayload{ResourceID: ResourceID(row.AvatarFrameResourceID), Name: row.AvatarFrameName, URL: row.AvatarFrameURL} - payload.EntryEffect = VipResourcePayload{ResourceID: ResourceID(row.EntryEffectResourceID), Name: row.EntryEffectName, URL: row.EntryEffectURL} - payload.ChatBubble = VipResourcePayload{ResourceID: ResourceID(row.ChatBubbleResourceID), Name: row.ChatBubbleName, URL: row.ChatBubbleURL} - payload.FloatPicture = VipResourcePayload{ResourceID: ResourceID(row.FloatPictureResourceID), Name: row.FloatPictureName, URL: row.FloatPictureURL} + payload.Badge = resourcePayload(row.BadgeResourceID, row.BadgeName, row.BadgeURL) + payload.AvatarFrame = resourcePayload(row.AvatarFrameResourceID, row.AvatarFrameName, row.AvatarFrameURL) + payload.EntryEffect = resourcePayload(row.EntryEffectResourceID, row.EntryEffectName, row.EntryEffectURL) + payload.ChatBubble = resourcePayload(row.ChatBubbleResourceID, row.ChatBubbleName, row.ChatBubbleURL) + payload.FloatPicture = resourcePayload(row.FloatPictureResourceID, row.FloatPictureName, row.FloatPictureURL) return payload }