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

225 lines
8.7 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 (
"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 行为。管理员下一次
// 保存会写回 2Update 入口仍通过严格校验拒绝新建 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)
}