2026-07-12 00:47:20 +08:00

180 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = &copy
continue
}
copy := item
baseline = &copy
}
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)