package appconfig import ( "context" "encoding/json" "errors" "fmt" "strings" "hyapp/pkg/giftlimits" ) const ( giftComboConfigGroup = "room-gift-combo" giftComboConfigKey = "default" ) const getGiftComboConfigSQL = ` SELECT app_code, COALESCE(value, ''), is_deleted, updated_at_ms FROM admin_app_configs WHERE app_code IN ('', ?) AND ` + "`group`" + ` = ? AND ` + "`key`" + ` = ? ORDER BY app_code ASC` // GiftComboConfig 是礼物面板下发给 Flutter 的运行时策略。 // 这里仅控制客户端如何封批和施加背压;账务数量、幂等和容量上限仍由 owner service 强制执行, // 因而旧客户端、篡改客户端或缓存了旧配置的客户端都不能绕过服务端保护。 type GiftComboConfig 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"` UpdatedAtMS int64 `json:"updated_at_ms"` } type storedGiftComboConfig struct { appCode string value string isDeleted bool updatedAtMS int64 } // DefaultGiftComboConfig keeps the feature usable when no admin row exists. A production kill switch // is an explicit scoped row with enabled=false, so a transient config read failure is never silently // converted into a different batching policy by this method. func DefaultGiftComboConfig() GiftComboConfig { return GiftComboConfig{ Enabled: true, APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, // 缺少后台配置时必须保持 0% 灰度。发布代码不能等价于全量打开财务主链路; // 运维确认 V2、Saga 和 MQ 指标后,再按 App 显式提高该值。 RolloutBasisPoints: 0, } } // GiftComboConfig returns the app-scoped remote policy. A scoped tombstone intentionally suppresses // the global baseline and falls back to code defaults, matching the admin_app_configs overlay contract. func (r *MySQLReader) GiftComboConfig(ctx context.Context, appCode string) (GiftComboConfig, error) { if r == nil || r.db == nil { return GiftComboConfig{}, errors.New("app config reader is not configured") } appCode = normalizeAppCode(appCode) rows, err := r.db.QueryContext(ctx, getGiftComboConfigSQL, appCode, giftComboConfigGroup, giftComboConfigKey) if err != nil { return GiftComboConfig{}, err } defer rows.Close() var baseline *storedGiftComboConfig var scoped *storedGiftComboConfig for rows.Next() { var item storedGiftComboConfig if err := rows.Scan(&item.appCode, &item.value, &item.isDeleted, &item.updatedAtMS); err != nil { return GiftComboConfig{}, err } if strings.EqualFold(strings.TrimSpace(item.appCode), appCode) { copy := item scoped = © continue } copy := item baseline = © } if err := rows.Err(); err != nil { return GiftComboConfig{}, err } selected := baseline if scoped != nil { if scoped.isDeleted { return DefaultGiftComboConfig(), nil } selected = scoped } if selected == nil || selected.isDeleted || strings.TrimSpace(selected.value) == "" { return DefaultGiftComboConfig(), nil } config, err := ParseGiftComboConfig(selected.value) if err != nil { return GiftComboConfig{}, fmt.Errorf("parse gift combo config for app %s: %w", appCode, err) } config.UpdatedAtMS = selected.updatedAtMS return config, nil } // ParseGiftComboConfig overlays a stored JSON object on safe defaults. This allows newly introduced // fields to acquire defaults while old admin rows remain valid during rolling upgrades. func ParseGiftComboConfig(raw string) (GiftComboConfig, error) { config := DefaultGiftComboConfig() if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &config); err != nil { return GiftComboConfig{}, err } if config.APIVersion == 1 { // 早期灰度配置曾把 1 当作客户端降级开关。读取时一次性提升为当前 V2 契约,避免 // 已有 admin_app_configs 行让礼物面板降级失败;新的配置写入仍只能提交 2。 config.APIVersion = 2 } return NormalizeGiftComboConfig(config) } // NormalizeGiftComboConfig rejects policies that could disable client backpressure or exceed the V2 // contract. Validation is deliberately shared with admin writes, but callers still enforce server caps. func NormalizeGiftComboConfig(config GiftComboConfig) (GiftComboConfig, error) { if config.APIVersion == 0 { config.APIVersion = 2 } if config.APIVersion != 2 { // api_version 是当前客户端契约标记,不是远程降级开关。旧 Flutter 自己固定调用 V1; // 新 Flutter 必须始终使用具备完整幂等结果重放语义的 V2。 return GiftComboConfig{}, fmt.Errorf("api_version must be 2") } if config.WindowMS < 100 || config.WindowMS > 1_000 { return GiftComboConfig{}, fmt.Errorf("window_ms must be between 100 and 1000") } if config.MaxGiftCountPerBatch < 1 || config.MaxGiftCountPerBatch > 999 { return GiftComboConfig{}, fmt.Errorf("max_gift_count_per_batch must be between 1 and 999") } if config.MaxGiftUnitsPerBatch < 1 || int64(config.MaxGiftUnitsPerBatch) > giftlimits.MaxTotalGiftUnits { return GiftComboConfig{}, fmt.Errorf("max_gift_units_per_batch must be between 1 and %d", giftlimits.MaxTotalGiftUnits) } if config.MaxInFlight < 1 || config.MaxInFlight > 2 { return GiftComboConfig{}, fmt.Errorf("max_in_flight must be between 1 and 2") } if config.MaxPendingBatches < 1 || config.MaxPendingBatches > 50 { return GiftComboConfig{}, fmt.Errorf("max_pending_batches must be between 1 and 50") } if config.MaxPendingBatches < config.MaxInFlight { // Flutter allocates in-flight work from the same bounded batch queue. A smaller queue cannot // represent the advertised concurrency and would make the client silently rewrite policy. return GiftComboConfig{}, fmt.Errorf("max_pending_batches must be greater than or equal to max_in_flight") } if config.RetryMaxAttempts < 1 || config.RetryMaxAttempts > 10 { return GiftComboConfig{}, fmt.Errorf("retry_max_attempts must be between 1 and 10") } if config.RetryBaseDelayMS < 100 || config.RetryBaseDelayMS > 5_000 { return GiftComboConfig{}, fmt.Errorf("retry_base_delay_ms must be between 100 and 5000") } if config.RolloutBasisPoints < 0 || config.RolloutBasisPoints > 10_000 { return GiftComboConfig{}, fmt.Errorf("rollout_basis_points must be between 0 and 10000") } return config, nil } var _ interface { GiftComboConfig(context.Context, string) (GiftComboConfig, error) } = (*MySQLReader)(nil)