180 lines
6.9 KiB
Go
180 lines
6.9 KiB
Go
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)
|