225 lines
8.7 KiB
Go
225 lines
8.7 KiB
Go
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)
|
||
}
|