package resource import ( "context" "database/sql" "errors" "fmt" "sort" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/response" walletv1 "hyapp.local/api/proto/wallet/v1" "github.com/gin-gonic/gin" ) type Handler struct { wallet walletclient.Client store *repository.Store userDB *sql.DB requestTimeout time.Duration audit shared.OperationLogger } func New(wallet walletclient.Client, store *repository.Store, userDB *sql.DB, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { if requestTimeout <= 0 { requestTimeout = 3 * time.Second } return &Handler{wallet: wallet, store: store, userDB: userDB, requestTimeout: requestTimeout, audit: audit} } func (h *Handler) walletRequestContext(c *gin.Context) (context.Context, context.CancelFunc) { // 资源后台在广州 admin-server 跨区访问 Saudi wallet-service;每次 RPC 必须继承 HTTP 取消信号并设置硬上限,避免坏连接拖到前置 504。 return context.WithTimeout(c.Request.Context(), h.requestTimeout) } func (h *Handler) ListResources(c *gin.Context) { options := shared.ListOptions(c) ctx, cancel := h.walletRequestContext(c) defer cancel() resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceType: strings.TrimSpace(c.Query("resource_type")), Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取资源列表失败") return } items := make([]resourceDTO, 0, len(resp.GetResources())) for _, item := range resp.GetResources() { items = append(items, resourceFromProto(item)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) GetResource(c *gin.Context) { resourceID, ok := parseID(c, "resource_id") if !ok { return } resp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceId: resourceID, }) if err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, resourceFromProto(resp.GetResource())) } func (h *Handler) CreateResource(c *gin.Context) { var req resourceRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源参数不正确") return } if err := validateResourcePricing(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceBadgeForm(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceAvatarFrameKind(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceMetadata(req); err != nil { response.BadRequest(c, err.Error()) return } resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) return } resource := resourceFromProto(resp.GetResource()) h.auditLog(c, "create-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode) response.Created(c, resource) } func (h *Handler) ListEmojiPacks(c *gin.Context) { options := shared.ListOptions(c) ctx, cancel := h.walletRequestContext(c) defer cancel() resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceType: resourceTypeEmojiPack, Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取表情包列表失败") return } items := make([]emojiPackDTO, 0, len(resp.GetResources())) for _, item := range resp.GetResources() { items = append(items, emojiPackFromProto(item)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) ListEmojiPackCategories(c *gin.Context) { const pageSize int32 = 100 categories := map[string]struct{}{} ctx, cancel := h.walletRequestContext(c) defer cancel() for page := int32(1); ; page++ { resp, err := h.wallet.ListResources(ctx, &walletv1.ListResourcesRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceType: resourceTypeEmojiPack, Page: page, PageSize: pageSize, }) if err != nil { response.ServerError(c, "获取表情包分类失败") return } for _, item := range resp.GetResources() { category := emojiPackMetadataFromJSON(item.GetMetadataJson()).Category if category != "" { categories[category] = struct{}{} } } if len(resp.GetResources()) == 0 || int64(page)*int64(pageSize) >= resp.GetTotal() { break } } items := make([]string, 0, len(categories)) for category := range categories { items = append(items, category) } sort.Strings(items) response.OK(c, items) } func (h *Handler) CreateEmojiPack(c *gin.Context) { var req emojiPackRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "表情包参数不正确") return } protoReq, err := req.createProto(c) if err != nil { response.BadRequest(c, err.Error()) return } resp, err := h.wallet.CreateResource(c.Request.Context(), protoReq) if err != nil { response.BadRequest(c, err.Error()) return } item := emojiPackFromProto(resp.GetResource()) h.auditLog(c, "create-emoji-pack", "resources", fmt.Sprintf("%d", item.ResourceID), "success", item.ResourceCode) response.Created(c, item) } func (h *Handler) UpdateResource(c *gin.Context) { resourceID, ok := parseID(c, "resource_id") if !ok { return } var req resourceRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源参数不正确") return } if err := validateResourcePricing(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceBadgeForm(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceAvatarFrameKind(req); err != nil { response.BadRequest(c, err.Error()) return } if err := validateResourceMetadata(req); err != nil { response.BadRequest(c, err.Error()) return } protoReq := req.updateProto(c, resourceID) resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq) if err != nil { response.BadRequest(c, err.Error()) return } resource := resourceFromProto(resp.GetResource()) h.auditLog(c, "update-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode) response.OK(c, resource) } func (h *Handler) UpdateMP4ResourceLayouts(c *gin.Context) { var req resourceMP4LayoutBatchRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "MP4 布局参数不正确") return } items, err := normalizeMP4LayoutBatchItems(req.Items) if err != nil { response.BadRequest(c, err.Error()) return } ctx, cancel := h.walletRequestContext(c) defer cancel() type preparedMP4LayoutUpdate struct { resource *walletv1.Resource metadataJSON string } prepared := make([]preparedMP4LayoutUpdate, 0, len(items)) for _, item := range items { resp, err := h.wallet.GetResource(ctx, &walletv1.GetResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceId: item.ResourceID, }) if err != nil { response.BadRequest(c, err.Error()) return } current := resp.GetResource() if current.GetResourceId() == 0 { response.BadRequest(c, "资源不存在") return } // 批量接口只更新真实 MP4 动效资源;非 MP4 行按产品要求自动跳过, // 这样前端全选资源后提交到后端,也不会把图片、SVGA、PAG 的 metadata 写坏。 if !resourceAnimationSourceIsMP4(current) { continue } metadataJSON, err := mp4LayoutMetadataForResource(current, item.MetadataJSON) if err != nil { response.BadRequest(c, err.Error()) return } prepared = append(prepared, preparedMP4LayoutUpdate{resource: current, metadataJSON: metadataJSON}) } updated := make([]resourceDTO, 0, len(prepared)) updatedIDs := make([]string, 0, len(prepared)) for _, item := range prepared { // wallet-service 只有全量资源更新 RPC,所以这里用当前资源还原所有业务字段, // 只替换 metadata_json,避免批量重算 MP4 时顺手覆盖价格、状态、资产或经理赠送配置。 resp, err := h.wallet.UpdateResource(ctx, updateResourceMP4MetadataProto(c, item.resource, item.metadataJSON)) if err != nil { response.BadRequest(c, err.Error()) return } resource := resourceFromProto(resp.GetResource()) updated = append(updated, resource) updatedIDs = append(updatedIDs, fmt.Sprintf("%d", resource.ResourceID)) } h.auditLog(c, "update-resource-mp4-layouts", "resources", "batch", "success", strings.Join(updatedIDs, ",")) response.OK(c, updated) } func (h *Handler) EnableResource(c *gin.Context) { h.setResourceStatus(c, "active") } func (h *Handler) DisableResource(c *gin.Context) { h.setResourceStatus(c, "disabled") } func (h *Handler) ListResourceGroups(c *gin.Context) { options := shared.ListOptions(c) ctx, cancel := h.walletRequestContext(c) defer cancel() resp, err := h.wallet.ListResourceGroups(ctx, &walletv1.ListResourceGroupsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取资源组列表失败") return } items := make([]resourceGroupDTO, 0, len(resp.GetGroups())) for _, item := range resp.GetGroups() { items = append(items, resourceGroupFromProto(item)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) GetResourceGroup(c *gin.Context) { groupID, ok := parseID(c, "group_id") if !ok { return } resp, err := h.wallet.GetResourceGroup(c.Request.Context(), &walletv1.GetResourceGroupRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GroupId: groupID, }) if err != nil { response.BadRequest(c, err.Error()) return } response.OK(c, resourceGroupFromProto(resp.GetGroup())) } func (h *Handler) CreateResourceGroup(c *gin.Context) { var req resourceGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源组参数不正确") return } resp, err := h.wallet.CreateResourceGroup(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) return } group := resourceGroupFromProto(resp.GetGroup()) h.auditLog(c, "create-resource-group", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", group.GroupCode) response.Created(c, group) } func (h *Handler) UpdateResourceGroup(c *gin.Context) { groupID, ok := parseID(c, "group_id") if !ok { return } var req resourceGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源组参数不正确") return } resp, err := h.wallet.UpdateResourceGroup(c.Request.Context(), req.updateProto(c, groupID)) if err != nil { response.BadRequest(c, err.Error()) return } group := resourceGroupFromProto(resp.GetGroup()) h.auditLog(c, "update-resource-group", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", group.GroupCode) response.OK(c, group) } func (h *Handler) EnableResourceGroup(c *gin.Context) { h.setResourceGroupStatus(c, "active") } func (h *Handler) DisableResourceGroup(c *gin.Context) { h.setResourceGroupStatus(c, "disabled") } func (h *Handler) ListGifts(c *gin.Context) { options := shared.ListOptions(c) regionID, filterRegion, ok := optionalInt64QueryWithPresence(c, "regionId", "region_id") if !ok { return } resp, err := h.wallet.ListGiftConfigs(c.Request.Context(), &walletv1.ListGiftConfigsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), RegionId: regionID, FilterRegion: filterRegion, GiftTypeCode: giftTypeCodeQuery(c), }) if err != nil { response.ServerError(c, "获取礼物列表失败") return } items := make([]giftDTO, 0, len(resp.GetGifts())) for _, item := range resp.GetGifts() { items = append(items, giftFromProto(item)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) ListGiftTypes(c *gin.Context) { resp, err := h.wallet.ListGiftTypeConfigs(c.Request.Context(), &walletv1.ListGiftTypeConfigsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), }) if err != nil { response.ServerError(c, "获取礼物类型失败") return } items := make([]giftTypeDTO, 0, len(resp.GetGiftTypes())) for _, item := range resp.GetGiftTypes() { items = append(items, giftTypeFromProto(item)) } response.OK(c, items) } func (h *Handler) UpdateGiftType(c *gin.Context) { typeCode := strings.TrimSpace(c.Param("type_code")) if typeCode == "" { response.BadRequest(c, "类型编码不正确") return } var req giftTypeRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "礼物类型参数不正确") return } resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), req.upsertProto(c, typeCode)) if err != nil { response.BadRequest(c, err.Error()) return } item := giftTypeFromProto(resp.GetGiftType()) h.auditLog(c, "update-gift-type", "gift_type_configs", item.TabKey, "success", item.TabName) response.OK(c, item) } func (h *Handler) UpdateGiftTypes(c *gin.Context) { var req giftTypesRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "礼物类型参数不正确") return } if len(req.Items) == 0 { response.BadRequest(c, "礼物类型不能为空") return } items := make([]giftTypeDTO, 0, len(req.Items)) tabKeys := make([]string, 0, len(req.Items)) for _, requestItem := range req.Items { tabKey := strings.TrimSpace(requestItem.TabKey) if tabKey == "" { response.BadRequest(c, "类型编码不正确") return } resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), requestItem.upsertProto(c)) if err != nil { response.BadRequest(c, err.Error()) return } item := giftTypeFromProto(resp.GetGiftType()) items = append(items, item) tabKeys = append(tabKeys, item.TabKey) } h.auditLog(c, "update-gift-types", "gift_type_configs", "batch", "success", strings.Join(tabKeys, ",")) response.OK(c, items) } func (h *Handler) CreateGift(c *gin.Context) { var req giftRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "礼物参数不正确") return } resp, err := h.wallet.CreateGiftConfig(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) return } gift := giftFromProto(resp.GetGift()) h.auditLog(c, "create-gift", "gift_configs", gift.GiftID, "success", gift.Name) response.Created(c, gift) } func (h *Handler) UpdateGift(c *gin.Context) { giftID := strings.TrimSpace(c.Param("gift_id")) if giftID == "" { response.BadRequest(c, "ID 参数不正确") return } var req giftRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "礼物参数不正确") return } resp, err := h.wallet.UpdateGiftConfig(c.Request.Context(), req.updateProto(c, giftID)) if err != nil { response.BadRequest(c, err.Error()) return } gift := giftFromProto(resp.GetGift()) h.auditLog(c, "update-gift", "gift_configs", gift.GiftID, "success", gift.Name) response.OK(c, gift) } func (h *Handler) DeleteGift(c *gin.Context) { giftID := strings.TrimSpace(c.Param("gift_id")) if giftID == "" { response.BadRequest(c, "ID 参数不正确") return } resp, err := h.wallet.DeleteGiftConfig(c.Request.Context(), &walletv1.DeleteGiftConfigRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GiftId: giftID, OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } gift := giftFromProto(resp.GetGift()) h.auditLog(c, "delete-gift", "gift_configs", gift.GiftID, "success", gift.Name) response.OK(c, gift) } func (h *Handler) EnableGift(c *gin.Context) { h.setGiftStatus(c, "active") } func (h *Handler) DisableGift(c *gin.Context) { h.setGiftStatus(c, "disabled") } func (h *Handler) GrantResource(c *gin.Context) { var req grantResourceRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源赠送参数不正确") return } targetUserID, err := parseGrantTargetUserID(req.TargetUserID) if err != nil { response.BadRequest(c, err.Error()) return } resp, err := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{ CommandId: strings.TrimSpace(req.CommandID), AppCode: appctx.FromContext(c.Request.Context()), TargetUserId: targetUserID, ResourceId: req.ResourceID, Quantity: req.Quantity, DurationMs: req.DurationMS, Reason: strings.TrimSpace(req.Reason), OperatorUserId: actorID(c), GrantSource: "admin", }) if err != nil { response.BadRequest(c, err.Error()) return } grant := grantFromProto(resp.GetGrant()) h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID)) response.Created(c, grant) } func (h *Handler) GrantResourceGroup(c *gin.Context) { var req grantGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "资源组赠送参数不正确") return } targetUserID, err := parseGrantTargetUserID(req.TargetUserID) if err != nil { response.BadRequest(c, err.Error()) return } resp, err := h.wallet.GrantResourceGroup(c.Request.Context(), &walletv1.GrantResourceGroupRequest{ CommandId: strings.TrimSpace(req.CommandID), AppCode: appctx.FromContext(c.Request.Context()), TargetUserId: targetUserID, GroupId: req.GroupID, Reason: strings.TrimSpace(req.Reason), OperatorUserId: actorID(c), GrantSource: "admin", }) if err != nil { response.BadRequest(c, err.Error()) return } grant := grantFromProto(resp.GetGrant()) h.auditLog(c, "grant-resource-group", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID)) response.Created(c, grant) } func (h *Handler) RevokeResourceGrant(c *gin.Context) { grantID := strings.TrimSpace(c.Param("grant_id")) if grantID == "" { response.BadRequest(c, "资源赠送记录不存在") return } resp, err := h.wallet.RevokeResourceGrant(c.Request.Context(), &walletv1.RevokeResourceGrantRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GrantId: grantID, Reason: "admin revoke resource grant", OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } grant := grantFromProto(resp.GetGrant()) h.auditLog(c, "revoke-resource-grant", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID)) response.OK(c, grant) } func (h *Handler) LookupResourceGrantTarget(c *gin.Context) { keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword")) if keyword == "" { response.BadRequest(c, "请输入用户 ID 或短号") return } if h == nil || h.userDB == nil { response.ServerError(c, "用户数据库未配置") return } // 资源赠送页允许输入短号,但 wallet 只认内部 user_id;这里在 admin 侧先解析, // 避免前端把短号直接发给 wallet,也避免 JS number 对长 user_id 造成精度损失。 nowMs := time.Now().UnixMilli() matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs) orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs) args := append([]any{appctx.FromContext(c.Request.Context())}, matchArgs...) args = append(args, orderArgs...) row := h.userDB.QueryRowContext(c.Request.Context(), ` SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '') FROM users u WHERE u.app_code = ? AND `+matchSQL+` ORDER BY `+orderSQL+`, u.user_id DESC LIMIT 1`, args..., ) user := grantUserDTO{} if err := row.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil { if err == sql.ErrNoRows { response.BadRequest(c, "用户不存在") return } response.ServerError(c, "查询用户失败") return } response.OK(c, user) } func (h *Handler) ListResourceGrants(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) targetUserID, _, err := shared.ResolveExactUserIDOrNumericFallback( c.Request.Context(), h.userDB, appCode, firstQuery(c, "target_user_id", "targetUserId"), time.Now().UnixMilli(), ) if err != nil { if errors.Is(err, shared.ErrInvalidUserIdentityFilter) { response.BadRequest(c, "target_user_id 参数不正确") return } response.ServerError(c, "查询赠送用户失败") return } resp, err := h.wallet.ListResourceGrants(c.Request.Context(), &walletv1.ListResourceGrantsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, TargetUserId: targetUserID, Status: options.Status, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取资源赠送记录失败") return } items := make([]grantDTO, 0, len(resp.GetGrants())) for _, item := range resp.GetGrants() { items = append(items, grantFromProto(item)) } if err := h.enrichGrantOperators(c.Request.Context(), items); err != nil { response.ServerError(c, "获取资源赠送操作人失败") return } if err := h.enrichGrantTargets(c.Request.Context(), items); err != nil { response.ServerError(c, "获取资源赠送用户信息失败") return } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) ListResourceShopItems(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.wallet.ListResourceShopItems(c.Request.Context(), &walletv1.ListResourceShopItemsRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceType: strings.TrimSpace(c.Query("resource_type")), Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取道具商店列表失败") return } items := make([]resourceShopItemDTO, 0, len(resp.GetItems())) for _, item := range resp.GetItems() { items = append(items, resourceShopItemFromProto(item)) } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) ListResourceShopPurchaseOrders(c *gin.Context) { options := shared.ListOptions(c) appCode := appctx.FromContext(c.Request.Context()) userID, ok := optionalInt64Query(c, "user_id", "userId") if !ok { return } userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user")) if userID == 0 && userKeyword != "" { resolvedUserID, matched, err := h.resolveResourceShopPurchaseUserID(c.Request.Context(), appCode, userKeyword) if err != nil { response.ServerError(c, "查询购买用户失败") return } if !matched { response.OK(c, response.Page{Items: []resourceShopPurchaseOrderDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0}) return } userID = resolvedUserID } ctx, cancel := h.walletRequestContext(c) defer cancel() resp, err := h.wallet.ListResourceShopPurchaseOrders(ctx, &walletv1.ListResourceShopPurchaseOrdersRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appCode, UserId: userID, ResourceType: strings.TrimSpace(c.Query("resource_type")), Status: options.Status, Keyword: options.Keyword, Page: int32(options.Page), PageSize: int32(options.PageSize), }) if err != nil { response.ServerError(c, "获取售卖记录失败") return } items := make([]resourceShopPurchaseOrderDTO, 0, len(resp.GetOrders())) for _, item := range resp.GetOrders() { items = append(items, resourceShopPurchaseOrderFromProto(item)) } if err := h.enrichResourceShopPurchaseUsers(c.Request.Context(), items); err != nil { response.ServerError(c, "获取购买用户信息失败") return } response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()}) } func (h *Handler) UpsertResourceShopItems(c *gin.Context) { var req resourceShopItemsRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "道具商店参数不正确") return } resp, err := h.wallet.UpsertResourceShopItems(c.Request.Context(), req.upsertProto(c)) if err != nil { response.BadRequest(c, err.Error()) return } items := make([]resourceShopItemDTO, 0, len(resp.GetItems())) resourceIDs := make([]string, 0, len(resp.GetItems())) for _, item := range resp.GetItems() { dto := resourceShopItemFromProto(item) items = append(items, dto) resourceIDs = append(resourceIDs, fmt.Sprintf("%d", dto.ResourceID)) } h.auditLog(c, "upsert-resource-shop-items", "resource_shop_items", "batch", "success", strings.Join(resourceIDs, ",")) response.OK(c, items) } func (h *Handler) EnableResourceShopItem(c *gin.Context) { h.setResourceShopItemStatus(c, "active") } func (h *Handler) DisableResourceShopItem(c *gin.Context) { h.setResourceShopItemStatus(c, "disabled") } func (h *Handler) setResourceStatus(c *gin.Context, status string) { resourceID, ok := parseID(c, "resource_id") if !ok { return } resp, err := h.wallet.SetResourceStatus(c.Request.Context(), &walletv1.SetResourceStatusRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceId: resourceID, Status: status, OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } resource := resourceFromProto(resp.GetResource()) h.auditLog(c, "set-resource-status", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", status) response.OK(c, resource) } func (h *Handler) setResourceGroupStatus(c *gin.Context, status string) { groupID, ok := parseID(c, "group_id") if !ok { return } resp, err := h.wallet.SetResourceGroupStatus(c.Request.Context(), &walletv1.SetResourceGroupStatusRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GroupId: groupID, Status: status, OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } group := resourceGroupFromProto(resp.GetGroup()) h.auditLog(c, "set-resource-group-status", "resource_groups", fmt.Sprintf("%d", group.GroupID), "success", status) response.OK(c, group) } func (h *Handler) setGiftStatus(c *gin.Context, status string) { giftID := strings.TrimSpace(c.Param("gift_id")) if giftID == "" { response.BadRequest(c, "ID 参数不正确") return } resp, err := h.wallet.SetGiftConfigStatus(c.Request.Context(), &walletv1.SetGiftConfigStatusRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), GiftId: giftID, Status: status, OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } gift := giftFromProto(resp.GetGift()) h.auditLog(c, "set-gift-status", "gift_configs", gift.GiftID, "success", status) response.OK(c, gift) } func (h *Handler) setResourceShopItemStatus(c *gin.Context, status string) { shopItemID, ok := parseID(c, "shop_item_id") if !ok { return } resp, err := h.wallet.SetResourceShopItemStatus(c.Request.Context(), &walletv1.SetResourceShopItemStatusRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ShopItemId: shopItemID, Status: status, OperatorUserId: actorID(c), }) if err != nil { response.BadRequest(c, err.Error()) return } item := resourceShopItemFromProto(resp.GetItem()) h.auditLog(c, "set-resource-shop-item-status", "resource_shop_items", fmt.Sprintf("%d", item.ShopItemID), "success", status) response.OK(c, item) } func (h *Handler) auditLog(c *gin.Context, action string, resource string, resourceID string, status string, detail string) { shared.OperationLogWithResourceID(c, h.audit, action, resource, resourceID, status, detail) } func actorID(c *gin.Context) int64 { return int64(shared.ActorFromContext(c).UserID) } func normalizeMP4LayoutBatchItems(items []resourceMP4LayoutUpdateRequest) ([]resourceMP4LayoutUpdateRequest, error) { const maxMP4LayoutBatchSize = 50 if len(items) == 0 { return nil, fmt.Errorf("请选择要更新的 MP4 资源") } if len(items) > maxMP4LayoutBatchSize { return nil, fmt.Errorf("单次最多更新 %d 个 MP4 资源", maxMP4LayoutBatchSize) } seen := map[int64]struct{}{} normalized := make([]resourceMP4LayoutUpdateRequest, 0, len(items)) for _, item := range items { if item.ResourceID <= 0 { return nil, fmt.Errorf("资源 ID 不正确") } if _, ok := seen[item.ResourceID]; ok { continue } seen[item.ResourceID] = struct{}{} normalized = append(normalized, resourceMP4LayoutUpdateRequest{ ResourceID: item.ResourceID, MetadataJSON: strings.TrimSpace(item.MetadataJSON), }) } if len(normalized) == 0 { return nil, fmt.Errorf("请选择要更新的 MP4 资源") } return normalized, nil } func resourceAnimationSourceIsMP4(item *walletv1.Resource) bool { if item == nil { return false } return resourceSourceIsMP4(item.GetAnimationUrl()) || resourceSourceIsMP4(item.GetAssetUrl()) } func resourceSourceIsMP4(value string) bool { source := strings.TrimSpace(value) if source == "" { return false } if before, _, ok := strings.Cut(source, "?"); ok { source = before } if before, _, ok := strings.Cut(source, "#"); ok { source = before } return strings.HasSuffix(strings.ToLower(source), ".mp4") } func mp4LayoutMetadataForResource(current *walletv1.Resource, metadataJSON string) (string, error) { incoming, fields, err := parseResourceMetadataPayload(metadataJSON) if err != nil { return "", fmt.Errorf("资源元数据格式不正确") } if incoming == nil { return "", fmt.Errorf("MP4 透明布局参数不完整") } if _, ok := fields["mp4_alpha_layout"]; !ok { return "", fmt.Errorf("MP4 透明布局参数不完整") } if err := validateResourceMetadata(resourceRequest{ ResourceType: current.GetResourceType(), MetadataJSON: metadataJSON, }); err != nil { return "", err } layout, err := sanitizeMP4AlphaLayoutMetadata(incoming.MP4AlphaLayout) if err != nil { return "", err } // 这个接口是“只更新 MP4 布局”的窄接口,所以其它 metadata 不信任前端请求; // 资料卡布局从当前资源保留,MP4 布局使用本次重算结果,最终再走统一 marshal 过滤空字段。 merged := resourceMetadataPayload{ AnimationFormat: resourceAnimationFormatMP4, MP4AlphaLayout: layout, } if current.GetResourceType() == resourceTypeProfileCard { currentPayload, _, _ := parseResourceMetadataPayload(current.GetMetadataJson()) if currentPayload != nil { merged.ProfileCardLayout = sanitizeProfileCardLayoutMetadata(currentPayload.ProfileCardLayout) } } return marshalResourceMetadataPayload(merged), nil } func updateResourceMP4MetadataProto(c *gin.Context, current *walletv1.Resource, metadataJSON string) *walletv1.UpdateResourceRequest { managerGrantEnabled := current.GetManagerGrantEnabled() return &walletv1.UpdateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), ResourceId: current.GetResourceId(), ResourceCode: current.GetResourceCode(), ResourceType: current.GetResourceType(), Name: current.GetName(), Status: current.GetStatus(), Grantable: current.GetGrantable(), GrantStrategy: current.GetGrantStrategy(), WalletAssetType: current.GetWalletAssetType(), WalletAssetAmount: current.GetWalletAssetAmount(), UsageScopes: append([]string(nil), current.GetUsageScopes()...), AssetUrl: current.GetAssetUrl(), PreviewUrl: current.GetPreviewUrl(), AnimationUrl: current.GetAnimationUrl(), MetadataJson: metadataJSON, SortOrder: current.GetSortOrder(), OperatorUserId: actorID(c), ManagerGrantEnabled: &managerGrantEnabled, PriceType: current.GetPriceType(), CoinPrice: current.GetCoinPrice(), } } func parseID(c *gin.Context, name string) (int64, bool) { raw := strings.TrimSpace(c.Param(name)) value, err := strconv.ParseInt(raw, 10, 64) if err != nil || value <= 0 { response.BadRequest(c, "ID 参数不正确") return 0, false } return value, true } func optionalInt64Query(c *gin.Context, primary string, fallback string) (int64, bool) { value, _, ok := optionalInt64QueryWithPresence(c, primary, fallback) return value, ok } func giftTypeCodeQuery(c *gin.Context) string { value := strings.TrimSpace(c.Query("giftTypeCode")) if value == "" { value = strings.TrimSpace(c.Query("gift_type_code")) } return value } func optionalInt64QueryWithPresence(c *gin.Context, primary string, fallback string) (int64, bool, bool) { raw := strings.TrimSpace(c.Query(primary)) if raw == "" { raw = strings.TrimSpace(c.Query(fallback)) } if raw == "" { return 0, false, true } value, err := strconv.ParseInt(raw, 10, 64) if err != nil || value < 0 { response.BadRequest(c, primary+" 参数不正确") return 0, false, false } return value, true, true } func firstQuery(c *gin.Context, keys ...string) string { for _, key := range keys { value := strings.TrimSpace(c.Query(key)) if value != "" { return value } } return "" }