diff --git a/server/admin/internal/modules/appconfig/gift_config.go b/server/admin/internal/modules/appconfig/gift_config.go new file mode 100644 index 00000000..89bea61c --- /dev/null +++ b/server/admin/internal/modules/appconfig/gift_config.go @@ -0,0 +1,110 @@ +package appconfig + +import ( + "encoding/json" + "errors" + "fmt" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/model" + + "gorm.io/gorm" +) + +const ( + giftConfigGroup = "room-gift" + giftQuantityPresetsKey = "quantity-presets" + giftQuantityPresetMaxCount = 8 + giftQuantityPresetMaxValue = 9_999 + giftQuantityPresetsDescription = "语音房送礼数量档位,严格递增且每档为 1-9999" +) + +var defaultGiftQuantityPresets = []int32{1, 9, 99, 999} + +type giftConfigRequest struct { + QuantityPresets []int32 `json:"quantityPresets"` +} + +// GiftConfig 是当前 App 的语音房送礼配置;缺少持久化行时仍返回兼容旧客户端的默认档位。 +type GiftConfig struct { + AppCode string `json:"appCode"` + QuantityPresets []int32 `json:"quantityPresets"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + +func (s *AppConfigService) GetGiftConfig(appCode string) (GiftConfig, error) { + appCode = appctx.Normalize(appCode) + item, err := s.store.GetOwnedAppConfig(appCode, giftConfigGroup, giftQuantityPresetsKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return GiftConfig{ + AppCode: appCode, + QuantityPresets: cloneGiftQuantityPresets(defaultGiftQuantityPresets), + }, nil + } + if err != nil { + return GiftConfig{}, err + } + return giftConfigFromModel(item) +} + +func (s *AppConfigService) UpdateGiftConfig(appCode string, request giftConfigRequest) (GiftConfig, error) { + appCode = appctx.Normalize(appCode) + presets, err := normalizeGiftQuantityPresets(request.QuantityPresets) + if err != nil { + return GiftConfig{}, err + } + raw, err := json.Marshal(presets) + if err != nil { + return GiftConfig{}, err + } + // App 作用域只取鉴权上下文;请求体没有 app_code,保存 Lalu 时不会覆盖 Fami 或其他 App。 + if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{ + AppCode: appCode, + Group: giftConfigGroup, + Key: giftQuantityPresetsKey, + Value: string(raw), + Description: giftQuantityPresetsDescription, + }}); err != nil { + return GiftConfig{}, err + } + return s.GetGiftConfig(appCode) +} + +func giftConfigFromModel(item model.AppConfig) (GiftConfig, error) { + var presets []int32 + if err := json.Unmarshal([]byte(item.Value), &presets); err != nil { + return GiftConfig{}, fmt.Errorf("送礼数量档位配置格式不正确: %w", err) + } + presets, err := normalizeGiftQuantityPresets(presets) + if err != nil { + return GiftConfig{}, err + } + return GiftConfig{ + AppCode: appctx.Normalize(item.AppCode), + QuantityPresets: presets, + UpdatedAtMS: item.UpdatedAtMS, + }, nil +} + +func normalizeGiftQuantityPresets(values []int32) ([]int32, error) { + if len(values) == 0 || len(values) > giftQuantityPresetMaxCount { + return nil, fmt.Errorf("送礼数量档位必须配置 1-%d 个", giftQuantityPresetMaxCount) + } + normalized := make([]int32, len(values)) + var previous int32 + for index, value := range values { + if value < 1 || value > giftQuantityPresetMaxValue { + return nil, fmt.Errorf("送礼数量档位必须在 1-%d 之间", giftQuantityPresetMaxValue) + } + if index > 0 && value <= previous { + return nil, errors.New("送礼数量档位必须严格递增且不能重复") + } + normalized[index] = value + previous = value + } + return normalized, nil +} + +func cloneGiftQuantityPresets(values []int32) []int32 { + return append([]int32(nil), values...) +} diff --git a/server/admin/internal/modules/appconfig/gift_config_handler.go b/server/admin/internal/modules/appconfig/gift_config_handler.go new file mode 100644 index 00000000..81c72082 --- /dev/null +++ b/server/admin/internal/modules/appconfig/gift_config_handler.go @@ -0,0 +1,43 @@ +package appconfig + +import ( + "fmt" + "strconv" + "strings" + + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/response" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) GetGiftConfig(c *gin.Context) { + item, err := h.service.GetGiftConfig(appctx.FromContext(c.Request.Context())) + if err != nil { + response.ServerError(c, "获取送礼配置失败") + return + } + response.OK(c, item) +} + +func (h *Handler) UpdateGiftConfig(c *gin.Context) { + var request giftConfigRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.BadRequest(c, "参数不正确") + return + } + appCode := appctx.FromContext(c.Request.Context()) + item, err := h.service.UpdateGiftConfig(appCode, request) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + // 审计记录最终规范化档位,避免仅有“保存成功”而无法还原当时对客户端下发的数量列表。 + parts := make([]string, 0, len(item.QuantityPresets)) + for _, value := range item.QuantityPresets { + parts = append(parts, strconv.FormatInt(int64(value), 10)) + } + shared.OperationLog(c, h.audit, "update-room-gift-config", "admin_app_configs", "success", fmt.Sprintf("app_code=%s quantity_presets=%s", item.AppCode, strings.Join(parts, ","))) + response.OK(c, item) +} diff --git a/server/admin/internal/modules/appconfig/routes.go b/server/admin/internal/modules/appconfig/routes.go index 9999cfda..ab51a5dd 100644 --- a/server/admin/internal/modules/appconfig/routes.go +++ b/server/admin/internal/modules/appconfig/routes.go @@ -16,6 +16,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.PUT("/admin/app-config/h5-links", middleware.RequirePermission("app-config:update"), h.UpdateH5Links) protected.PUT("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.UpdateH5Link) protected.DELETE("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.DeleteH5Link) + protected.GET("/admin/app-config/gift", middleware.RequirePermission("app-config:view"), h.GetGiftConfig) + protected.PUT("/admin/app-config/gift", middleware.RequirePermission("app-config:update"), h.UpdateGiftConfig) protected.GET("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:view"), h.GetGiftComboConfig) protected.PUT("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:update"), h.UpdateGiftComboConfig) protected.GET("/admin/app-config/floating-screen", middleware.RequirePermission("app-config:view"), h.GetFloatingScreenConfig) diff --git a/server/admin/migrations/126_gift_quantity_presets_config.sql b/server/admin/migrations/126_gift_quantity_presets_config.sql new file mode 100644 index 00000000..7ac8d1ff --- /dev/null +++ b/server/admin/migrations/126_gift_quantity_presets_config.sql @@ -0,0 +1,25 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 查询与写入都命中 uk_admin_app_config_app_group_key(app_code, group, key),只处理两个常量行, +-- 不扫描礼物、账务或配置事实表。重复执行时保留管理员已经保存的值,避免回滚线上档位。 +INSERT INTO admin_app_configs (app_code, `group`, `key`, value, description, is_deleted, created_at_ms, updated_at_ms) +VALUES + ('lalu', 'room-gift', 'quantity-presets', '[1,7,77,777,7777]', '语音房送礼数量档位,严格递增且每档为 1-9999', FALSE, @now_ms, @now_ms), + ('fami', 'room-gift', 'quantity-presets', '[1,7,77,777,7777]', '语音房送礼数量档位,严格递增且每档为 1-9999', FALSE, @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE id = id; + +INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) +SELECT parent.id, '送礼配置', 'app-config-gift', '/app-config/gift', 'gift', 'app-config:view', 74, TRUE, @now_ms, @now_ms +FROM admin_menus parent +WHERE parent.code = 'app-config' +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), + title = VALUES(title), + path = VALUES(path), + icon = VALUES(icon), + permission_code = VALUES(permission_code), + sort = VALUES(sort), + visible = VALUES(visible), + updated_at_ms = VALUES(updated_at_ms); diff --git a/services/gateway-service/internal/appconfig/gift_config.go b/services/gateway-service/internal/appconfig/gift_config.go new file mode 100644 index 00000000..3fd7d01b --- /dev/null +++ b/services/gateway-service/internal/appconfig/gift_config.go @@ -0,0 +1,74 @@ +package appconfig + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + giftConfigGroup = "room-gift" + giftQuantityPresetsKey = "quantity-presets" + giftQuantityPresetMaxCount = 8 + giftQuantityPresetMaxValue = 9_999 +) + +const getGiftQuantityPresetsSQL = ` + SELECT COALESCE(value, '') + FROM admin_app_configs + WHERE app_code = ? AND ` + "`group`" + ` = ? AND ` + "`key`" + ` = ? AND is_deleted = FALSE + LIMIT 1` + +// DefaultGiftQuantityPresets preserves the existing product behavior for Apps that have not saved +// their own room-gift configuration. Callers receive a copy so request-local fallback cannot mutate it. +func DefaultGiftQuantityPresets() []int32 { + return []int32{1, 9, 99, 999} +} + +// GiftQuantityPresets resolves only the selected App's row. The three-column unique index makes this +// a bounded point lookup; a missing row is a normal compatibility fallback, not a cross-App baseline. +func (r *MySQLReader) GiftQuantityPresets(ctx context.Context, appCode string) ([]int32, error) { + if r == nil || r.db == nil { + return nil, errors.New("app config reader is not configured") + } + appCode = normalizeAppCode(appCode) + var raw string + err := r.db.QueryRowContext(ctx, getGiftQuantityPresetsSQL, appCode, giftConfigGroup, giftQuantityPresetsKey).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return DefaultGiftQuantityPresets(), nil + } + if err != nil { + return nil, err + } + var presets []int32 + if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &presets); err != nil { + return nil, fmt.Errorf("parse gift quantity presets for app %s: %w", appCode, err) + } + return normalizeGiftQuantityPresets(presets) +} + +func normalizeGiftQuantityPresets(values []int32) ([]int32, error) { + if len(values) == 0 || len(values) > giftQuantityPresetMaxCount { + return nil, fmt.Errorf("gift quantity presets must contain between 1 and %d values", giftQuantityPresetMaxCount) + } + normalized := make([]int32, len(values)) + var previous int32 + for index, value := range values { + if value < 1 || value > giftQuantityPresetMaxValue { + return nil, fmt.Errorf("gift quantity preset must be between 1 and %d", giftQuantityPresetMaxValue) + } + if index > 0 && value <= previous { + return nil, errors.New("gift quantity presets must be strictly increasing and unique") + } + normalized[index] = value + previous = value + } + return normalized, nil +} + +var _ interface { + GiftQuantityPresets(context.Context, string) ([]int32, error) +} = (*MySQLReader)(nil) diff --git a/services/gateway-service/internal/transport/http/roomapi/gift_config_policy.go b/services/gateway-service/internal/transport/http/roomapi/gift_config_policy.go new file mode 100644 index 00000000..98577b09 --- /dev/null +++ b/services/gateway-service/internal/transport/http/roomapi/gift_config_policy.go @@ -0,0 +1,18 @@ +package roomapi + +import ( + "context" + + "hyapp/services/gateway-service/internal/appconfig" +) + +type giftQuantityPresetReader interface { + GiftQuantityPresets(ctx context.Context, appCode string) ([]int32, error) +} + +func (h *Handler) roomGiftQuantityPresets(ctx context.Context, appCode string) ([]int32, error) { + if h.giftQuantityReader == nil { + return appconfig.DefaultGiftQuantityPresets(), nil + } + return h.giftQuantityReader.GiftQuantityPresets(ctx, appCode) +} diff --git a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go index 7c61d99a..8d1ba756 100644 --- a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go +++ b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline.go @@ -162,6 +162,11 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R // 当前 Flutter 无论是否聚合都继续走 V2,不能因配置故障切回旧财务入口。 return config, err != nil, err }) + quantityPresetsCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "quantity_presets", func() ([]int32, bool, error) { + presets, err := h.roomGiftQuantityPresets(totalCtx, app) + // 配置库故障不能阻断礼物面板;降级到旧档位并保留阶段错误,便于区分未配置和运行时依赖异常。 + return presets, err != nil, err + }) snapshotResp, _, err := waitRoomGiftPanelStage(totalCtx, snapshotCh) if err != nil { @@ -219,6 +224,14 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R } comboConfig = disabledGiftComboFallback() } + quantityPresets, _, quantityPresetsErr := waitRoomGiftPanelStage(totalCtx, quantityPresetsCh) + if quantityPresetsErr != nil { + if totalCtx.Err() != nil { + h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, quantityPresetsErr) + return + } + quantityPresets = appconfig.DefaultGiftQuantityPresets() + } panelGifts := append([]giftConfigData{}, panelConfig.Gifts...) panelGifts = append(panelGifts, roomGiftBagGiftsFromResources(bagResources, panelConfig.Gifts, snapshot.GetVisibleRegionId())...) @@ -227,7 +240,7 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R Recipients: roomGiftRecipients(snapshot, recipientProfiles), Tabs: roomGiftTabs(panelGifts, panelConfig.GiftTypes), Gifts: panelGifts, - QuantityPresets: []int32{1, 9, 99, 999}, + QuantityPresets: quantityPresets, ComboConfig: comboConfig, } logRoomGiftPanelStage(totalCtx, requestID, roomID, "total", totalStartedAt, nil, false) diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index b892b8cb..f380887b 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -43,6 +43,7 @@ type Handler struct { userHostClient client.UserHostClient appConfigReader appapi.ConfigReader giftComboReader giftComboConfigReader + giftQuantityReader giftQuantityPresetReader walletClient client.WalletClient giftPanelCache *roomGiftPanelConfigCache growthLevelClient client.GrowthLevelClient @@ -102,6 +103,7 @@ func New(config Config) *Handler { giftMaxUnits = 5_000 } comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader) + giftQuantityReader, _ := any(config.AppConfigReader).(giftQuantityPresetReader) roomVIPClient, _ := any(config.RoomClient).(client.RoomVIPFeatureClient) roomMediaGuard, _ := any(config.RoomGuardClient).(client.RoomMediaGuardClient) urlVerifier, _ := any(config.ObjectUploader).(objectURLVerifier) @@ -120,6 +122,7 @@ func New(config Config) *Handler { userHostClient: config.UserHostClient, appConfigReader: config.AppConfigReader, giftComboReader: comboReader, + giftQuantityReader: giftQuantityReader, walletClient: config.WalletClient, giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second), growthLevelClient: config.GrowthLevelClient,