package resource import ( "encoding/json" "fmt" "strings" "time" "unicode/utf8" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/middleware" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/gin-gonic/gin" ) const dayMillis int64 = 24 * 60 * 60 * 1000 const ( resourceTypeCoin = "coin" resourceTypeBadge = "badge" resourceTypeEmojiPack = "emoji_pack" ) const ( resourcePriceTypeCoin = "coin" resourcePriceTypeFree = "free" ) const ( badgeFormStrip = "strip" badgeFormTile = "tile" ) const ( defaultEmojiPackCategory = "默认" emojiPackPricingFree = "free" emojiPackPricingPaid = "paid" ) type resourceRequest struct { ResourceCode string `json:"resourceCode"` ResourceType string `json:"resourceType"` Name string `json:"name"` Status string `json:"status"` Amount int64 `json:"amount"` PriceType string `json:"priceType"` CoinPrice int64 `json:"coinPrice"` GiftPointAmount int64 `json:"giftPointAmount"` BadgeForm string `json:"badgeForm"` ManagerGrantEnabled *bool `json:"managerGrantEnabled"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` AnimationURL string `json:"animationUrl"` SortOrder int32 `json:"sortOrder"` } type emojiPackRequest struct { Name string `json:"name"` Category string `json:"category"` PricingType string `json:"pricingType"` CoverURL string `json:"coverUrl"` AnimationURL string `json:"animationUrl"` RegionIDs []int64 `json:"regionIds"` SortOrder int32 `json:"sortOrder"` } type emojiPackMetadataPayload struct { Category string `json:"category"` PricingType string `json:"pricing_type"` RegionIDs []int64 `json:"region_ids,omitempty"` RegionScope string `json:"region_scope"` } type badgeMetadataPayload struct { BadgeForm string `json:"badge_form"` DefaultSlot string `json:"default_slot,omitempty"` } type resourceGroupRequest struct { GroupCode string `json:"groupCode"` Name string `json:"name"` Status string `json:"status"` Description string `json:"description"` SortOrder int32 `json:"sortOrder"` Items []resourceGroupItemRequest `json:"items"` } type resourceGroupItemRequest struct { ItemType string `json:"itemType"` ResourceID int64 `json:"resourceId"` AssetType string `json:"assetType"` Amount int64 `json:"amount"` DurationDays int64 `json:"durationDays"` SortOrder int32 `json:"sortOrder"` } type giftRequest struct { GiftID string `json:"giftId"` ResourceID int64 `json:"resourceId"` Status string `json:"status"` Name string `json:"name"` SortOrder int32 `json:"sortOrder"` PresentationJSON string `json:"presentationJson"` PriceVersion string `json:"priceVersion"` GiftTypeCode string `json:"giftTypeCode"` ChargeAssetType string `json:"chargeAssetType"` CoinPrice int64 `json:"coinPrice"` GiftPointAmount int64 `json:"giftPointAmount"` HeatValue int64 `json:"heatValue"` EffectiveAtMS int64 `json:"effectiveAtMs"` EffectiveFromMS int64 `json:"effectiveFromMs"` EffectiveToMS int64 `json:"effectiveToMs"` EffectTypes []string `json:"effectTypes"` RegionIDs []int64 `json:"regionIds"` } type giftTypeRequest struct { DisplayName string `json:"displayName"` TabName string `json:"tabName"` Status string `json:"status"` SortOrder int32 `json:"sortOrder"` } type grantResourceRequest struct { CommandID string `json:"commandId"` TargetUserID int64 `json:"targetUserId"` ResourceID int64 `json:"resourceId"` Quantity int64 `json:"quantity"` DurationMS int64 `json:"durationMs"` Reason string `json:"reason"` } type grantGroupRequest struct { CommandID string `json:"commandId"` TargetUserID int64 `json:"targetUserId"` GroupID int64 `json:"groupId"` Reason string `json:"reason"` } func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest { resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) return &walletv1.CreateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceCode: strings.TrimSpace(r.ResourceCode), ResourceType: resourceType, Name: strings.TrimSpace(r.Name), Status: strings.TrimSpace(r.Status), Grantable: true, ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled), GrantStrategy: resourceGrantStrategy(resourceType), WalletAssetType: walletAssetType, WalletAssetAmount: walletAssetAmount, PriceType: priceType, CoinPrice: coinPrice, GiftPointAmount: giftPointAmount, UsageScopes: []string{resourceType}, AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), AnimationUrl: strings.TrimSpace(r.AnimationURL), MetadataJson: metadataJSON, SortOrder: r.SortOrder, OperatorUserId: actorID(c), } } func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest { resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm) return &walletv1.UpdateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceId: resourceID, ResourceCode: strings.TrimSpace(r.ResourceCode), ResourceType: resourceType, Name: strings.TrimSpace(r.Name), Status: strings.TrimSpace(r.Status), Grantable: true, ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled), GrantStrategy: resourceGrantStrategy(resourceType), WalletAssetType: walletAssetType, WalletAssetAmount: walletAssetAmount, PriceType: priceType, CoinPrice: coinPrice, GiftPointAmount: giftPointAmount, UsageScopes: []string{resourceType}, AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), AnimationUrl: strings.TrimSpace(r.AnimationURL), MetadataJson: metadataJSON, SortOrder: r.SortOrder, OperatorUserId: actorID(c), } } func (r emojiPackRequest) createProto(c *gin.Context) (*walletv1.CreateResourceRequest, error) { name := strings.TrimSpace(r.Name) coverURL := strings.TrimSpace(r.CoverURL) animationURL := strings.TrimSpace(r.AnimationURL) if name == "" || coverURL == "" || animationURL == "" { return nil, fmt.Errorf("表情包参数不完整") } category := normalizeEmojiPackCategory(r.Category) if utf8.RuneCountInString(category) > 64 { return nil, fmt.Errorf("表情包分类不能超过 64 个字符") } metadataJSON, err := emojiPackMetadataJSON(r.RegionIDs, category, r.PricingType) if err != nil { return nil, err } return &walletv1.CreateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceCode: fmt.Sprintf("emoji_pack_%d", time.Now().UTC().UnixNano()), ResourceType: resourceTypeEmojiPack, Name: name, Status: "active", Grantable: false, ManagerGrantEnabled: boolPointer(false), GrantStrategy: "increase_quantity", UsageScopes: []string{resourceTypeEmojiPack}, AssetUrl: coverURL, PreviewUrl: coverURL, AnimationUrl: animationURL, MetadataJson: metadataJSON, SortOrder: r.SortOrder, OperatorUserId: actorID(c), }, nil } func (r resourceGroupRequest) createProto(c *gin.Context) *walletv1.CreateResourceGroupRequest { return &walletv1.CreateResourceGroupRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GroupCode: strings.TrimSpace(r.GroupCode), Name: strings.TrimSpace(r.Name), Status: strings.TrimSpace(r.Status), Description: strings.TrimSpace(r.Description), SortOrder: r.SortOrder, Items: groupItemInputs(r.Items), OperatorUserId: actorID(c), } } func (r resourceGroupRequest) updateProto(c *gin.Context, groupID int64) *walletv1.UpdateResourceGroupRequest { return &walletv1.UpdateResourceGroupRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GroupId: groupID, GroupCode: strings.TrimSpace(r.GroupCode), Name: strings.TrimSpace(r.Name), Status: strings.TrimSpace(r.Status), Description: strings.TrimSpace(r.Description), SortOrder: r.SortOrder, Items: groupItemInputs(r.Items), OperatorUserId: actorID(c), } } func (r giftRequest) createProto(c *gin.Context) *walletv1.CreateGiftConfigRequest { return &walletv1.CreateGiftConfigRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GiftId: strings.TrimSpace(r.GiftID), ResourceId: r.ResourceID, Status: strings.TrimSpace(r.Status), Name: strings.TrimSpace(r.Name), SortOrder: r.SortOrder, PresentationJson: strings.TrimSpace(r.PresentationJSON), PriceVersion: strings.TrimSpace(r.PriceVersion), GiftTypeCode: strings.TrimSpace(r.GiftTypeCode), ChargeAssetType: strings.TrimSpace(r.ChargeAssetType), CoinPrice: r.CoinPrice, GiftPointAmount: r.GiftPointAmount, HeatValue: r.HeatValue, EffectiveAtMs: r.EffectiveAtMS, EffectiveFromMs: r.EffectiveFromMS, EffectiveToMs: r.EffectiveToMS, EffectTypes: r.EffectTypes, OperatorUserId: actorID(c), RegionIds: r.RegionIDs, } } func (r giftRequest) updateProto(c *gin.Context, giftID string) *walletv1.UpdateGiftConfigRequest { return &walletv1.UpdateGiftConfigRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GiftId: strings.TrimSpace(giftID), ResourceId: r.ResourceID, Status: strings.TrimSpace(r.Status), Name: strings.TrimSpace(r.Name), SortOrder: r.SortOrder, PresentationJson: strings.TrimSpace(r.PresentationJSON), PriceVersion: strings.TrimSpace(r.PriceVersion), GiftTypeCode: strings.TrimSpace(r.GiftTypeCode), ChargeAssetType: strings.TrimSpace(r.ChargeAssetType), CoinPrice: r.CoinPrice, GiftPointAmount: r.GiftPointAmount, HeatValue: r.HeatValue, EffectiveAtMs: r.EffectiveAtMS, EffectiveFromMs: r.EffectiveFromMS, EffectiveToMs: r.EffectiveToMS, EffectTypes: r.EffectTypes, OperatorUserId: actorID(c), RegionIds: r.RegionIDs, } } func (r giftTypeRequest) upsertProto(c *gin.Context, typeCode string) *walletv1.UpsertGiftTypeConfigRequest { return &walletv1.UpsertGiftTypeConfigRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), TypeCode: strings.TrimSpace(typeCode), Name: strings.TrimSpace(r.DisplayName), TabKey: strings.TrimSpace(r.TabName), Status: strings.TrimSpace(r.Status), SortOrder: r.SortOrder, OperatorUserId: actorID(c), } } func managerGrantEnabledOrDefault(value *bool) *bool { enabled := true if value != nil { enabled = *value } return &enabled } func groupItemInputs(items []resourceGroupItemRequest) []*walletv1.ResourceGroupItemInput { out := make([]*walletv1.ResourceGroupItemInput, 0, len(items)) for _, item := range items { itemType := strings.ToLower(strings.TrimSpace(item.ItemType)) if itemType == "wallet_asset" { out = append(out, &walletv1.ResourceGroupItemInput{ ItemType: itemType, WalletAssetType: strings.TrimSpace(item.AssetType), WalletAssetAmount: item.Amount, Quantity: 1, SortOrder: item.SortOrder, }) continue } out = append(out, &walletv1.ResourceGroupItemInput{ ItemType: "resource", ResourceId: item.ResourceID, Quantity: 1, DurationMs: item.DurationDays * dayMillis, SortOrder: item.SortOrder, }) } return out } func normalizeResourceType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func resourceWalletAsset(resourceType string, amount int64) (string, int64) { if resourceType == resourceTypeCoin { return "COIN", amount } return "", 0 } func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) { priceType = strings.ToLower(strings.TrimSpace(priceType)) if priceType == resourcePriceTypeFree { return resourcePriceTypeFree, 0, 0 } return resourcePriceTypeCoin, coinPrice, coinPrice } func validateResourcePricing(req resourceRequest) error { priceType := strings.ToLower(strings.TrimSpace(req.PriceType)) switch priceType { case resourcePriceTypeCoin: if req.CoinPrice <= 0 { return fmt.Errorf("请输入资源价格") } case resourcePriceTypeFree: return nil default: return fmt.Errorf("请选择资源价格类型") } return nil } func validateResourceBadgeForm(req resourceRequest) error { if normalizeResourceType(req.ResourceType) != resourceTypeBadge { return nil } if normalizeBadgeForm(req.BadgeForm) == "" { return fmt.Errorf("请选择徽章属性") } return nil } func resourceGrantStrategy(resourceType string) string { switch resourceType { case resourceTypeCoin: return "wallet_credit" case "avatar_frame", "profile_card", "vehicle", "chat_bubble": return "extend_expiry" case "badge": return "set_active_flag" default: return "increase_quantity" } } func resourceMetadataJSON(resourceType string, badgeForm string) string { if resourceType != resourceTypeBadge { return "{}" } payload := badgeMetadataPayload{BadgeForm: normalizeBadgeForm(badgeForm)} switch payload.BadgeForm { case badgeFormStrip: payload.DefaultSlot = "profile_strip" case badgeFormTile: payload.DefaultSlot = "honor_wall" default: return "{}" } body, err := json.Marshal(payload) if err != nil { return "{}" } return string(body) } func normalizeBadgeForm(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case badgeFormStrip, "long": return badgeFormStrip case badgeFormTile, "short": return badgeFormTile default: return "" } } func badgeFormFromMetadata(metadataJSON string) string { payload := badgeMetadataPayload{} if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil { return "" } return normalizeBadgeForm(payload.BadgeForm) } func badgeFormForResource(resourceType string, metadataJSON string) string { if normalizeResourceType(resourceType) != resourceTypeBadge { return "" } return badgeFormFromMetadata(metadataJSON) } func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) { normalizedRegionIDs := normalizeRegionIDs(regionIDs) metadata := emojiPackMetadataPayload{ Category: normalizeEmojiPackCategory(category), PricingType: normalizeEmojiPackPricingType(pricingType), RegionScope: "all", } if utf8.RuneCountInString(metadata.Category) > 64 { return "", fmt.Errorf("表情包分类不能超过 64 个字符") } if len(normalizedRegionIDs) > 0 { metadata.RegionScope = "limited" metadata.RegionIDs = normalizedRegionIDs } body, err := json.Marshal(metadata) if err != nil { return "", fmt.Errorf("表情包配置不正确") } return string(body), nil } func emojiPackMetadataFromJSON(metadataJSON string) emojiPackMetadataPayload { payload := emojiPackMetadataPayload{ Category: defaultEmojiPackCategory, PricingType: emojiPackPricingFree, RegionScope: "all", } if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil { return payload } payload.Category = normalizeEmojiPackCategory(payload.Category) payload.PricingType = normalizeEmojiPackPricingType(payload.PricingType) payload.RegionIDs = normalizeRegionIDs(payload.RegionIDs) if len(payload.RegionIDs) > 0 { payload.RegionScope = "limited" } else { payload.RegionScope = "all" } return payload } func normalizeEmojiPackCategory(value string) string { category := strings.TrimSpace(value) if category == "" { return defaultEmojiPackCategory } return category } func normalizeEmojiPackPricingType(value string) string { if strings.ToLower(strings.TrimSpace(value)) == emojiPackPricingPaid { return emojiPackPricingPaid } return emojiPackPricingFree } func normalizeRegionIDs(regionIDs []int64) []int64 { out := make([]int64, 0, len(regionIDs)) seen := make(map[int64]struct{}, len(regionIDs)) for _, regionID := range regionIDs { if regionID == 0 { return nil } if regionID < 0 { continue } if _, ok := seen[regionID]; ok { continue } seen[regionID] = struct{}{} out = append(out, regionID) } return out } func boolPointer(value bool) *bool { return &value }