2026-06-26 16:20:58 +08:00

1072 lines
35 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"
"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 ""
}