package appconfig import ( "encoding/json" "errors" "fmt" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const ( giftComboConfigGroup = "room-gift-combo" giftComboConfigKey = "default" ) // GiftComboConfigRequest is intentionally a full replacement document. Operators can inspect the // current normalized response before updating it, and enabled=false is therefore an unambiguous // one-click kill switch rather than an omitted boolean accidentally inheriting true. type GiftComboConfigRequest struct { Enabled bool `json:"enabled"` APIVersion int32 `json:"apiVersion"` WindowMS int32 `json:"windowMs"` MaxGiftCountPerBatch int32 `json:"maxGiftCountPerBatch"` MaxGiftUnitsPerBatch int32 `json:"maxGiftUnitsPerBatch"` MaxInFlight int32 `json:"maxInFlight"` MaxPendingBatches int32 `json:"maxPendingBatches"` RetryMaxAttempts int32 `json:"retryMaxAttempts"` RetryBaseDelayMS int32 `json:"retryBaseDelayMs"` RolloutBasisPoints int32 `json:"rolloutBasisPoints"` } type storedGiftComboConfig struct { Enabled bool `json:"enabled"` APIVersion int32 `json:"api_version"` WindowMS int32 `json:"window_ms"` MaxGiftCountPerBatch int32 `json:"max_gift_count_per_batch"` MaxGiftUnitsPerBatch int32 `json:"max_gift_units_per_batch"` MaxInFlight int32 `json:"max_in_flight"` MaxPendingBatches int32 `json:"max_pending_batches"` RetryMaxAttempts int32 `json:"retry_max_attempts"` RetryBaseDelayMS int32 `json:"retry_base_delay_ms"` RolloutBasisPoints int32 `json:"rollout_basis_points"` } type GiftComboConfigDTO struct { GiftComboConfigRequest AppCode string `json:"appCode"` UpdatedAtMS int64 `json:"updatedAtMs"` } func defaultStoredGiftComboConfig() storedGiftComboConfig { return storedGiftComboConfig{ Enabled: true, APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, // 新 App 没有配置行时只展示安全的 0% 灰度,必须由管理员显式放量。 RolloutBasisPoints: 0, } } func normalizeStoredGiftComboConfig(config storedGiftComboConfig) (storedGiftComboConfig, error) { if config.APIVersion == 0 { config.APIVersion = 2 } if config.APIVersion != 2 { // 后台只允许调整微批窗口、背压和灰度;接口版本不是运营开关,避免把当前 Flutter // 从具备完整结算结果重放的 V2 静默切回 V1。 return storedGiftComboConfig{}, errors.New("apiVersion must be 2") } if config.WindowMS < 100 || config.WindowMS > 1_000 { return storedGiftComboConfig{}, errors.New("windowMs must be between 100 and 1000") } if config.MaxGiftCountPerBatch < 1 || config.MaxGiftCountPerBatch > 999 { return storedGiftComboConfig{}, errors.New("maxGiftCountPerBatch must be between 1 and 999") } if config.MaxGiftUnitsPerBatch < 1 || config.MaxGiftUnitsPerBatch > 5_000 { return storedGiftComboConfig{}, errors.New("maxGiftUnitsPerBatch must be between 1 and 5000") } if config.MaxInFlight < 1 || config.MaxInFlight > 2 { return storedGiftComboConfig{}, errors.New("maxInFlight must be between 1 and 2") } if config.MaxPendingBatches < 1 || config.MaxPendingBatches > 50 { return storedGiftComboConfig{}, errors.New("maxPendingBatches must be between 1 and 50") } if config.MaxPendingBatches < config.MaxInFlight { // The admin contract must reject policies Flutter cannot execute literally; normalizing this // mismatch on-device would make an audited configuration differ from production behavior. return storedGiftComboConfig{}, errors.New("maxPendingBatches must be greater than or equal to maxInFlight") } if config.RetryMaxAttempts < 1 || config.RetryMaxAttempts > 10 { return storedGiftComboConfig{}, errors.New("retryMaxAttempts must be between 1 and 10") } if config.RetryBaseDelayMS < 100 || config.RetryBaseDelayMS > 5_000 { return storedGiftComboConfig{}, errors.New("retryBaseDelayMs must be between 100 and 5000") } if config.RolloutBasisPoints < 0 || config.RolloutBasisPoints > 10_000 { return storedGiftComboConfig{}, errors.New("rolloutBasisPoints must be between 0 and 10000") } return config, nil } func normalizeStoredGiftComboConfigForRead(config storedGiftComboConfig) (storedGiftComboConfig, error) { if config.APIVersion == 1 { // 兼容早期已落库的灰度行,但只迁移存储标记,不恢复客户端 V1 行为。管理员下一次 // 保存会写回 2;Update 入口仍通过严格校验拒绝新建 apiVersion=1 配置。 config.APIVersion = 2 } return normalizeStoredGiftComboConfig(config) } func storedGiftComboConfigFromRequest(request GiftComboConfigRequest) (storedGiftComboConfig, error) { return normalizeStoredGiftComboConfig(storedGiftComboConfig{ Enabled: request.Enabled, APIVersion: request.APIVersion, WindowMS: request.WindowMS, MaxGiftCountPerBatch: request.MaxGiftCountPerBatch, MaxGiftUnitsPerBatch: request.MaxGiftUnitsPerBatch, MaxInFlight: request.MaxInFlight, MaxPendingBatches: request.MaxPendingBatches, RetryMaxAttempts: request.RetryMaxAttempts, RetryBaseDelayMS: request.RetryBaseDelayMS, RolloutBasisPoints: request.RolloutBasisPoints, }) } func giftComboConfigDTO(appCode string, config storedGiftComboConfig, updatedAtMS int64) GiftComboConfigDTO { return GiftComboConfigDTO{ AppCode: appctx.Normalize(appCode), UpdatedAtMS: updatedAtMS, GiftComboConfigRequest: GiftComboConfigRequest{ Enabled: config.Enabled, APIVersion: config.APIVersion, WindowMS: config.WindowMS, MaxGiftCountPerBatch: config.MaxGiftCountPerBatch, MaxGiftUnitsPerBatch: config.MaxGiftUnitsPerBatch, MaxInFlight: config.MaxInFlight, MaxPendingBatches: config.MaxPendingBatches, RetryMaxAttempts: config.RetryMaxAttempts, RetryBaseDelayMS: config.RetryBaseDelayMS, RolloutBasisPoints: config.RolloutBasisPoints, }, } } func (s *AppConfigService) GetGiftComboConfig(appCode string) (GiftComboConfigDTO, error) { appCode = appctx.Normalize(appCode) item, err := s.store.GetScopedAppConfig(appCode, giftComboConfigGroup, giftComboConfigKey) if errors.Is(err, gorm.ErrRecordNotFound) { return giftComboConfigDTO(appCode, defaultStoredGiftComboConfig(), 0), nil } if err != nil { return GiftComboConfigDTO{}, err } config := defaultStoredGiftComboConfig() if err := json.Unmarshal([]byte(strings.TrimSpace(item.Value)), &config); err != nil { return GiftComboConfigDTO{}, fmt.Errorf("stored gift combo config is invalid: %w", err) } config, err = normalizeStoredGiftComboConfigForRead(config) if err != nil { return GiftComboConfigDTO{}, err } return giftComboConfigDTO(appCode, config, item.UpdatedAtMS), nil } func (s *AppConfigService) UpdateGiftComboConfig(appCode string, request GiftComboConfigRequest) (GiftComboConfigDTO, error) { appCode = appctx.Normalize(appCode) config, err := storedGiftComboConfigFromRequest(request) if err != nil { return GiftComboConfigDTO{}, err } raw, err := json.Marshal(config) if err != nil { return GiftComboConfigDTO{}, err } // UpsertScopedAppConfigs writes only the selected app_code, so a rollout or kill switch for one App // cannot unexpectedly alter every tenant sharing this admin database. if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{ AppCode: appCode, Group: giftComboConfigGroup, Key: giftComboConfigKey, Value: string(raw), Description: "Flutter room gift combo batching policy", }}); err != nil { return GiftComboConfigDTO{}, err } return s.GetGiftComboConfig(appCode) } func (h *Handler) GetGiftComboConfig(c *gin.Context) { item, err := h.service.GetGiftComboConfig(appctx.FromContext(c.Request.Context())) if err != nil { response.ServerError(c, "获取送礼连击配置失败") return } response.OK(c, item) } func (h *Handler) UpdateGiftComboConfig(c *gin.Context) { var request GiftComboConfigRequest if err := c.ShouldBindJSON(&request); err != nil { response.BadRequest(c, "参数不正确") return } item, err := h.service.UpdateGiftComboConfig(appctx.FromContext(c.Request.Context()), request) if err != nil { response.BadRequest(c, err.Error()) return } shared.OperationLog(c, h.audit, "update-room-gift-combo", "admin_app_configs", "success", fmt.Sprintf("app_code=%s enabled=%t rollout_bps=%d", item.AppCode, item.Enabled, item.RolloutBasisPoints)) response.OK(c, item) }