1293 lines
43 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()})
}
// ListExternalGrantResources exposes only the owner-approved manager grant catalog.
// VIP trial cards remain on their dedicated API because a generic entitlement grant
// would not update the VIP state projection. The external route requires the complete
// grant permission bundle before this handler is reached.
func (h *Handler) ListExternalGrantResources(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")),
Keyword: options.Keyword,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
ActiveOnly: true,
ManagerGrantOnly: true,
})
if err != nil {
response.ServerError(c, "获取可发放资源失败")
return
}
items := make([]resourceDTO, 0, len(resp.GetResources()))
excluded := int64(0)
for _, item := range resp.GetResources() {
if item.GetResourceType() == resourceTypeVIPTrialCard {
excluded++
continue
}
items = append(items, resourceFromProto(item))
}
// ManagerGrantOnly is indexed owner data; excluding a dedicated-flow item here is
// defense in depth. Total can only be reduced by rows observed on this page because
// the wallet contract does not expose a negative type filter.
total := resp.GetTotal() - excluded
if total < int64(len(items)) {
total = int64(len(items))
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
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) DeleteResource(c *gin.Context) {
resourceID, ok := parseID(c, "resource_id")
if !ok {
return
}
resp, err := h.wallet.DeleteResource(c.Request.Context(), &walletv1.DeleteResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
resource := resourceFromProto(resp.GetResource())
h.auditLog(c, "delete-resource", "resources", fmt.Sprintf("%d", resource.ResourceID), "success", resource.ResourceCode)
response.OK(c, resource)
}
func (h *Handler) BatchDeleteResources(c *gin.Context) {
var req resourceDeleteBatchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.ResourceIDs) == 0 {
response.BadRequest(c, "资源参数不正确")
return
}
resp, err := h.wallet.BatchDeleteResources(c.Request.Context(), &walletv1.BatchDeleteResourcesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceIds: req.ResourceIDs,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
items := make([]resourceDTO, 0, len(resp.GetResources()))
deletedIDs := make([]string, 0, len(resp.GetResources()))
for _, item := range resp.GetResources() {
resource := resourceFromProto(item)
items = append(items, resource)
deletedIDs = append(deletedIDs, fmt.Sprintf("%d", resource.ResourceID))
}
h.auditLog(c, "delete-resources", "resources", "batch", "success", strings.Join(deletedIDs, ","))
response.OK(c, items)
}
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) BatchCreateGifts(c *gin.Context) {
var req giftBatchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.Items) == 0 {
response.BadRequest(c, "礼物参数不正确")
return
}
items := make([]*walletv1.CreateGiftConfigRequest, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, item.createProto(c))
}
resp, err := h.wallet.BatchCreateGiftConfigs(c.Request.Context(), &walletv1.BatchCreateGiftConfigsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
Items: items,
OperatorUserId: actorID(c),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
gifts := make([]giftDTO, 0, len(resp.GetGifts()))
giftIDs := make([]string, 0, len(resp.GetGifts()))
for _, item := range resp.GetGifts() {
gift := giftFromProto(item)
gifts = append(gifts, gift)
giftIDs = append(giftIDs, gift.GiftID)
}
h.auditLog(c, "create-gifts", "gift_configs", "batch", "success", strings.Join(giftIDs, ","))
response.Created(c, gifts)
}
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) {
h.grantResource(c, false)
}
// GrantExternalResource keeps main-admin behavior unchanged while closing the ID
// bypass on the external portal: callers may only submit an active resource that the
// owner explicitly marked grantable for managers.
func (h *Handler) GrantExternalResource(c *gin.Context) {
h.grantResource(c, true)
}
func (h *Handler) grantResource(c *gin.Context, external bool) {
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
}
if err := h.validateGenericGrantResource(c, req.ResourceID, external); err != nil {
response.BadRequest(c, err.Error())
return
}
grantSource := "admin"
if external {
// Wallet atomically rechecks manager_grant_enabled for manager_center at grant
// time. This closes the GetResource/GrantResource race and records the actual
// external operator channel in the entitlement ledger.
grantSource = grantSourceManagerCenter
}
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: grantSource,
})
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
}
if err := h.validateGenericGrantGroup(c, req.GroupID); 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) validateGenericGrantResource(c *gin.Context, resourceID int64, requireManagerGrant bool) error {
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 {
return err
}
if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) {
return fmt.Errorf("该资源未开放外管发放")
}
// VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影;
// 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。
if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
return fmt.Errorf("VIP 体验卡必须通过 VIP 配置页赠送")
}
return nil
}
func (h *Handler) validateGenericGrantGroup(c *gin.Context, groupID int64) error {
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 {
return err
}
for _, item := range resp.GetGroup().GetItems() {
resourceType := item.GetResource().GetResourceType()
// 老资源组数据可能只返回 resource_id逐项回查可防止因嵌套 Resource 缺失而绕过体验卡专用发放链路。
if resourceType == "" && item.GetResourceId() > 0 {
resourceResp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: item.GetResourceId(),
})
if err != nil {
return err
}
resourceType = resourceResp.GetResource().GetResourceType()
}
if resourceType == resourceTypeVIPTrialCard {
return fmt.Errorf("包含 VIP 体验卡的资源组必须通过 VIP 配置页赠送")
}
}
return nil
}
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())
targetFilter := shared.UserIdentityFilterFromQuery(c, "target")
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
}
if targetUserID == 0 && !targetFilter.IsEmpty() {
var matched bool
targetUserID, matched, err = shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, targetFilter, time.Now().UnixMilli())
if err != nil {
response.ServerError(c, "查询赠送用户失败")
return
}
if !matched {
response.OK(c, response.Page{Items: []grantDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
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
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userKeyword := strings.TrimSpace(firstQuery(c, "user_keyword", "userKeyword", "user"))
if userID == 0 && !userFilter.IsEmpty() {
resolvedUserID, matched, err := shared.ResolveUserIdentityFilter(c.Request.Context(), h.userDB, appCode, userFilter, time.Now().UnixMilli())
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
}
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 ""
}