2026-06-11 17:11:59 +08:00

982 lines
31 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 resource
import (
"context"
"database/sql"
"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 := 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 := 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) 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 造成精度损失。
row := h.userDB.QueryRowContext(c.Request.Context(), `
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
FROM users
WHERE app_code = ?
AND (CAST(user_id AS CHAR) = ? OR current_display_user_id = ?)
ORDER BY
CASE
WHEN CAST(user_id AS CHAR) = ? THEN 0
WHEN current_display_user_id = ? THEN 1
ELSE 2
END,
user_id DESC
LIMIT 1`,
appctx.FromContext(c.Request.Context()), keyword, keyword, keyword, keyword,
)
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)
targetUserID, ok := optionalInt64Query(c, "target_user_id", "targetUserId")
if !ok {
return
}
resp, err := h.wallet.ListResourceGrants(c.Request.Context(), &walletv1.ListResourceGrantsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
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) 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 ""
}