package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"slices"
	"sort"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
)

const (
	bizTypeResourceGrant        = "resource_grant"
	bizTypeResourceShopPurchase = "resource_shop_purchase"
	resourceOutboxAsset         = "RESOURCE"
)

type scanTarget interface {
	Scan(dest ...any) error
}

type sqlRowQuerier interface {
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

type sqlQuerier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

type sqlExecer interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

// ListResources 返回后台资源库列表；App 入口会设置 active_only 只读上架资源。
func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceListQuery(query)

	where, args := resourceWhereSQL(query)
	total, err := r.countResourceRows(ctx, "resources", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+`
		`+where+`
		ORDER BY sort_order ASC, resource_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]resourcedomain.Resource, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResource(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// GetResource 读取单个资源详情。
func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

// CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。
func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		INSERT INTO resources (
		   app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy,
		   wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
		   animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
		   created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		command.PriceType,
		command.CoinPrice,
		command.GiftPointAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		command.OperatorUserID,
		nowMs,
		nowMs,
	)
	if err != nil {
		return resourcedomain.Resource{}, mapResourceWriteError(err)
	}
	resourceID, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	resource, err := r.getResourceForUpdate(ctx, tx, resourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

// UpdateResource 全量更新资源配置；当前开发阶段不做兼容性合并。
func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ResourceID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceCommand(command)
	if err := validateResourceCommand(command); err != nil {
		return resourcedomain.Resource{}, err
	}

	nowMs := time.Now().UnixMilli()
	usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		UPDATE resources
		SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?,
		    grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?,
		    price_type = ?, coin_price = ?, gift_point_amount = ?,
		    usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?,
		    metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND resource_id = ?`,
		command.ResourceCode,
		command.ResourceType,
		command.Name,
		command.Status,
		command.Grantable,
		command.ManagerGrantEnabled,
		command.GrantStrategy,
		command.WalletAssetType,
		command.WalletAssetAmount,
		command.PriceType,
		command.CoinPrice,
		command.GiftPointAmount,
		usageJSON,
		command.AssetURL,
		command.PreviewURL,
		command.AnimationURL,
		metadataJSON,
		command.SortOrder,
		command.OperatorUserID,
		nowMs,
		command.AppCode,
		command.ResourceID,
	)
	if err != nil {
		return resourcedomain.Resource{}, mapResourceWriteError(err)
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	events := []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs),
	}
	giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	events = append(events, giftEvents...)
	if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) {
	// 礼物资源的价格是后台编辑入口，实际送礼扣费读取 wallet_gift_prices。
	// 因此资源价格变更后，必须在同一个事务内把已绑定礼物的 active 价格同步过去，
	// 同时发 GiftConfigChanged outbox，让依赖礼物配置快照的下游可以刷新缓存。
	if resource.ResourceType != resourcedomain.TypeGift {
		return nil, nil
	}
	priceType := resourcedomain.NormalizePriceType(resource.PriceType)
	if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree {
		return nil, nil
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT gift_id
		FROM gift_configs
		WHERE app_code = ? AND resource_id = ?
		ORDER BY gift_id`,
		appcode.FromContext(ctx), resource.ResourceID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	giftIDs := make([]string, 0)
	for rows.Next() {
		var giftID string
		if err := rows.Scan(&giftID); err != nil {
			return nil, err
		}
		giftIDs = append(giftIDs, giftID)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if len(giftIDs) == 0 {
		return nil, nil
	}

	coinPrice := int64(0)
	if priceType == resourcedomain.PriceTypeCoin {
		coinPrice = resource.CoinPrice
	}
	// gift_point_amount 是历史字段，资源改价同步礼物价格时固定写 0，避免旧积分口径重新进入送礼链路。
	giftPointAmount := int64(0)
	if _, err := tx.ExecContext(ctx, `
		UPDATE wallet_gift_prices gp
		JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id
		SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ?
		WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`,
		ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID,
	); err != nil {
		return nil, err
	}
	events := make([]walletOutboxEvent, 0, len(giftIDs))
	for _, giftID := range giftIDs {
		events = append(events, resourceOutboxEvent(
			"GiftConfigChanged",
			fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs),
			0,
			resource.ResourceID,
			map[string]any{
				"gift_id":           giftID,
				"resource_id":       resource.ResourceID,
				"coin_price":        coinPrice,
				"gift_point_amount": giftPointAmount,
			},
			nowMs,
		))
	}
	return events, nil
}

func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) {
	if r == nil || r.db == nil {
		return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ID <= 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	nowMs := time.Now().UnixMilli()
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	defer func() { _ = tx.Rollback() }()
	result, err := tx.ExecContext(ctx,
		`UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`,
		command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID,
	)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.Resource{}, err
	} else if affected == 0 {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	resource, err := r.getResourceForUpdate(ctx, tx, command.ID)
	if err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs),
	}); err != nil {
		return resourcedomain.Resource{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.Resource{}, err
	}
	return resource, nil
}

func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceGroupListQuery(query)

	where, args := resourceGroupWhereSQL(query)
	total, err := r.countResourceRows(ctx, "resource_groups", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, group_id, group_code, name, status, description, sort_order,
		       created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		FROM resource_groups
		`+where+`
		ORDER BY sort_order ASC, group_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	groups := make([]resourcedomain.ResourceGroup, 0, query.PageSize)
	for rows.Next() {
		group, err := scanResourceGroup(rows)
		if err != nil {
			return nil, 0, err
		}
		group.Items, err = r.listResourceGroupItems(ctx, group.GroupID)
		if err != nil {
			return nil, 0, err
		}
		groups = append(groups, group)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return groups, total, nil
}

func (r *Repository) GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	group, err := r.getResourceGroup(ctx, r.db, groupID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	group.Items, err = r.listResourceGroupItems(ctx, groupID)
	return group, err
}

func (r *Repository) CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceGroupCommand(command)
	if err := validateResourceGroupCommand(command, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		INSERT INTO resource_groups (
		   app_code, group_code, name, status, description, sort_order,
		   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder,
		command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	groupID, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.replaceResourceGroupItemsTx(ctx, tx, groupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:create:%d", groupID, nowMs), 0, groupID, command, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return r.GetResourceGroup(ctx, groupID)
}

func (r *Repository) UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.GroupID <= 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceGroupCommand(command)
	if err := validateResourceGroupCommand(command, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		UPDATE resource_groups
		SET group_code = ?, name = ?, status = ?, description = ?, sort_order = ?,
		    updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND group_id = ?`,
		command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder,
		command.OperatorUserID, nowMs, command.AppCode, command.GroupID,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	if err := r.replaceResourceGroupItemsTx(ctx, tx, command.GroupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:update:%d", command.GroupID, nowMs), 0, command.GroupID, command, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return r.GetResourceGroup(ctx, command.GroupID)
}

func (r *Repository) SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if command.ID <= 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := r.getResourceGroup(ctx, tx, command.ID, true); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if command.Status == resourcedomain.StatusActive {
		items, err := r.listResourceGroupItemsTx(ctx, tx, command.ID, true)
		if err != nil {
			return resourcedomain.ResourceGroup{}, err
		}
		if err := validateActiveResourceGroupItems(items); err != nil {
			return resourcedomain.ResourceGroup{}, err
		}
	}
	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx,
		`UPDATE resource_groups SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`,
		command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID,
	)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	group, err := r.getResourceGroup(ctx, tx, command.ID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.ID, false)
	if err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, group, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGroup{}, err
	}
	return group, nil
}

func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeGiftConfigListQuery(query)
	where, args := giftConfigWhereSQL(query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM gift_configs gc
		JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
		`+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+`
		ORDER BY gc.sort_order ASC, gc.created_at_ms DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	nowMs := time.Now().UnixMilli()
	items := make([]resourcedomain.GiftConfig, 0, query.PageSize)
	for rows.Next() {
		item, err := scanGiftConfig(rows)
		if err != nil {
			return nil, 0, err
		}
		item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID)
		if err != nil {
			return nil, 0, err
		}
		price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs)
		if priceErr == nil {
			item.PriceVersion = price.PriceVersion
			item.ChargeAssetType = price.ChargeAssetType
			item.CoinPrice = price.CoinPrice
			item.GiftPointAmount = 0
			item.HeatValue = price.CoinPrice
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// ListGiftTypeConfigs 返回礼物面板 tab 类型配置；默认类型会按 app_code 自动补齐，避免新租户缺配置。
func (r *Repository) ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	if err := r.ensureDefaultGiftTypeConfigs(ctx, r.db); err != nil {
		return nil, err
	}
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeGiftTypeConfigListQuery(query)
	where, args := giftTypeConfigWhereSQL(query)
	rows, err := r.db.QueryContext(ctx, giftTypeConfigSelectSQL()+where+`
		ORDER BY sort_order ASC, type_code ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	items := make([]resourcedomain.GiftTypeConfig, 0, len(resourcedomain.DefaultGiftTypeConfigs(query.AppCode)))
	for rows.Next() {
		item, err := scanGiftTypeConfig(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

func (r *Repository) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
	return r.upsertGiftConfig(ctx, command, false)
}

func (r *Repository) UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) {
	return r.upsertGiftConfig(ctx, command, true)
}

func (r *Repository) SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if strings.TrimSpace(command.StringID) == "" {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	status := resourcedomain.NormalizeStatus(command.Status)
	if !resourcedomain.ValidStatus(status) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	nowMs := time.Now().UnixMilli()
	result, err := r.db.ExecContext(ctx,
		`UPDATE gift_configs SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`,
		status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), strings.TrimSpace(command.StringID),
	)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.GiftConfig{}, err
	} else if affected == 0 {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}
	gift, err := r.getGiftConfig(ctx, strings.TrimSpace(command.StringID))
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	_ = r.insertResourceOutboxNoTx(ctx, "GiftConfigChanged", fmt.Sprintf("gift:%s:status:%s:%d", gift.GiftID, status, nowMs), 0, gift.ResourceID, gift)
	return gift, nil
}

func (r *Repository) DeleteGiftConfig(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if strings.TrimSpace(command.StringID) == "" {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	giftID := strings.TrimSpace(command.StringID)
	nowMs := time.Now().UnixMilli()
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	// 删除礼物配置前先锁定并读取完整快照：送礼链路也会锁 gift_configs，
	// 因此后台删除和正在结算的同一礼物不会交叉执行成半删状态。
	gift, err := r.getGiftConfigForUpdateTx(ctx, tx, giftID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	result, err := tx.ExecContext(ctx, `DELETE FROM gift_configs WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.GiftConfig{}, err
	} else if affected == 0 {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}

	deletedGift := gift
	deletedGift.Status = "deleted"
	// 只删除礼物列表配置和配置附属表，不删除 resources；资源素材仍留在资源列表，可重新绑定或复用。
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:delete:%d", giftID, nowMs), 0, gift.ResourceID, deletedGift, nowMs),
	}); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return gift, nil
}

// UpsertGiftTypeConfig 更新礼物类型 tab 展示配置；type_code 是礼物表引用的稳定业务键。
func (r *Repository) UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGiftTypeConfigCommand(command)
	if err := validateGiftTypeConfigCommand(command); err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()
	if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}
	nowMs := time.Now().UnixMilli()
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO gift_type_configs (
			app_code, type_code, name, tab_key, status, sort_order,
			created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			name = VALUES(name),
			tab_key = VALUES(tab_key),
			status = VALUES(status),
			sort_order = VALUES(sort_order),
			updated_by_user_id = VALUES(updated_by_user_id),
			updated_at_ms = VALUES(updated_at_ms)`,
		command.AppCode, command.TypeCode, command.Name, command.TabKey, command.Status, command.SortOrder,
		command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
	); err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}
	item, err := r.getGiftTypeConfigTx(ctx, tx, command.TypeCode)
	if err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.GiftTypeConfig{}, err
	}
	return item, nil
}

// GrantResource 发放单个资源；金币资源和非金币权益在同一个事务里处理。
func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGrantResourceCommand(command)
	if err := validateGrantResourceCommand(command); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := grantResourceRequestHash(command)
	if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		return r.getResourceGrantTx(ctx, tx, grantID)
	}
	nowMs := time.Now().UnixMilli()
	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

// GrantResourceGroup 锁定资源组快照并原子发放组内所有资源。
func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGrantResourceGroupCommand(command)
	if err := validateGrantResourceGroupCommand(command); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := grantResourceGroupRequestHash(command)
	if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		return r.getResourceGrantTx(ctx, tx, grantID)
	}
	nowMs := time.Now().UnixMilli()
	group, err := r.getResourceGroup(ctx, tx, command.GroupID, true)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if group.Status != resourcedomain.StatusActive {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group is disabled")
	}
	group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.GroupID, true)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if len(group.Items) == 0 {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	groupSnapshot, err := json.Marshal(group)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	for _, item := range group.Items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
			if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil {
				return resourcedomain.ResourceGrant{}, err
			}
			continue
		}
		if err := validateActiveResourceGroupResource(item.Resource); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
		if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil {
			return resourcedomain.ResourceGrant{}, err
		}
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	return r.GetResourceGrant(ctx, grantID)
}

func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	if query.UserID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	nowMs := time.Now().UnixMilli()
	// equipment 表只记录“当前佩戴指针”，真正的有效期仍以 entitlement 为准。
	// 所以背包列表读取前先清理当前用户已失效的佩戴指针，避免过期道具继续显示 equipped=true。
	pruneResourceTypes := []string{}
	where := `WHERE e.app_code = ? AND e.user_id = ?`
	args := []any{appcode.FromContext(ctx), query.UserID}
	if strings.TrimSpace(query.ResourceType) != "" {
		resourceType := resourcedomain.NormalizeResourceType(query.ResourceType)
		if !resourcedomain.ValidResourceType(resourceType) {
			return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid")
		}
		// 只有可佩戴资源才可能在 equipment 表中留下指针；礼物/金币等非佩戴资源不参与清理。
		if isEquipableResourceType(resourceType) {
			pruneResourceTypes = []string{resourceType}
		}
		where += ` AND r.resource_type = ?`
		args = append(args, resourceType)
	}
	if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil {
		return nil, err
	}
	if query.ActiveOnly {
		// App 背包只需要当前可用权益；后台或排障读取可关闭 ActiveOnly 看历史/过期记录。
		where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0`
		args = append(args, nowMs, nowMs)
	}
	rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.UserResourceEntitlement, 0)
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.EntitlementID = strings.TrimSpace(command.EntitlementID)
	if command.UserID <= 0 || command.ResourceID <= 0 {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user_id and resource_id are required")
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs)
	if err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if !isEquipableResourceType(entitlement.Resource.ResourceType) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if err := r.equipUserResourceEntitlementTx(ctx, tx, command.UserID, entitlement, nowMs); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	entitlement.Equipped = true
	eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{
			"action":        "equip",
			"app_code":      command.AppCode,
			"user_id":       command.UserID,
			"resource_id":   command.ResourceID,
			"resource_type": entitlement.Resource.ResourceType,
			"entitlement":   entitlement,
			"updated_at_ms": nowMs,
			"event_command": eventCommandID,
		}, nowMs),
	}); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	return entitlement, nil
}

func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) {
	if r == nil || r.db == nil {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	if command.UserID <= 0 || command.ResourceType == "" {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) {
		return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if resourcedomain.NormalizeResourceType(command.ResourceType) == resourcedomain.TypeBadge {
		// 徽章是“拥有即佩戴”的展示资源，不再支持按类型整类卸下。
		// 旧客户端如果仍调用 badge unequip，按幂等无变更返回，避免把用户全部徽章一次删掉。
		return resourcedomain.UnequipUserResourceResult{
			ResourceType: command.ResourceType,
			Unequipped:   false,
			UpdatedAtMS:  time.Now().UnixMilli(),
		}, nil
	}

	// 取消佩戴要和 outbox 事件在同一事务内提交：
	// UI 投影可以监听 UserResourceChanged，但不能在 equipment 删除失败时收到“已卸下”的假事件。
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		DELETE FROM user_resource_equipment
		WHERE app_code = ? AND user_id = ? AND resource_type = ?`,
		command.AppCode, command.UserID, command.ResourceType,
	)
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	// RowsAffected=0 表示用户本来就没佩戴该类型；这个场景按幂等取消处理，不抛业务错误。
	affected, err := result.RowsAffected()
	if err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	if affected > 0 {
		// resource_id 对“取消某一类佩戴”没有单一值，payload 用 resource_type 表示变更维度。
		eventCommandID := fmt.Sprintf("unequip:%d:%s:%d", command.UserID, command.ResourceType, time.Now().UnixNano())
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
			resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, 0, map[string]any{
				"action":        "unequip",
				"app_code":      command.AppCode,
				"user_id":       command.UserID,
				"resource_type": command.ResourceType,
				"updated_at_ms": nowMs,
				"event_command": eventCommandID,
			}, nowMs),
		}); err != nil {
			return resourcedomain.UnequipUserResourceResult{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.UnequipUserResourceResult{}, err
	}
	return resourcedomain.UnequipUserResourceResult{
		ResourceType: command.ResourceType,
		Unequipped:   affected > 0,
		UpdatedAtMS:  nowMs,
	}, nil
}

func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	userIDs := compactPositiveInt64s(query.UserIDs)
	if len(userIDs) == 0 {
		return []resourcedomain.UserEquippedResources{}, nil
	}
	// 批量查询只允许可佩戴类型，避免把背包里不可展示为装扮的资源混进房间资料。
	resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes)
	if err != nil {
		return nil, err
	}
	nowMs := time.Now().UnixMilli()
	// 先清理再查，保证调用方不需要理解“equipment 只是指针”的存储细节。
	if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil {
		return nil, err
	}

	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
	args = append(args, nowMs, nowMs)
	typeFilter := ""
	if len(resourceTypes) > 0 {
		// resourceTypes 为空表示查询所有可佩戴类型；非空时只返回调用方关心的展示维度。
		typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)`
		args = append(args, stringAnyArgs(resourceTypes)...)
	}
	// 从 equipment 反查 entitlement/resource，而不是从 entitlement 扫描 equipped 标记：
	// 这样非徽章类型仍由写入侧保证每类一个，徽章则可以按同一个 resource_type 返回多个已佩戴权益。
	rows, err := r.db.QueryContext(ctx, `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.created_at_ms, e.updated_at_ms,
		       TRUE AS equipped,
		       `+resourceColumnsWithAlias("r")+`
		FROM user_resource_equipment eq
		JOIN user_resource_entitlements e ON e.app_code = eq.app_code
		 AND e.user_id = eq.user_id
		 AND e.resource_id = eq.resource_id
		 AND e.entitlement_id = eq.entitlement_id
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`)
		  AND e.status = 'active'
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		  AND e.remaining_quantity > 0
		  AND r.status = 'active'`+typeFilter+`
		ORDER BY eq.user_id ASC, eq.updated_at_ms DESC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs))
	for rows.Next() {
		item, err := scanUserResourceEntitlement(rows)
		if err != nil {
			return nil, err
		}
		item.Equipped = true
		grouped[item.UserID] = append(grouped[item.UserID], item)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	// 返回顺序按请求 user_ids 保持稳定，调用方可以直接和入参用户列表对齐。
	result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs))
	for _, userID := range userIDs {
		result = append(result, resourcedomain.UserEquippedResources{
			UserID:    userID,
			Resources: grouped[userID],
		})
	}
	return result, nil
}

func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceGrantsQuery(query)
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.TargetUserID > 0 {
		where += ` AND target_user_id = ?`
		args = append(args, query.TargetUserID)
	}
	if strings.TrimSpace(query.Status) != "" {
		where += ` AND status = ?`
		args = append(args, strings.ToLower(strings.TrimSpace(query.Status)))
	}
	total, err := r.countResourceRows(ctx, "resource_grants", where, args...)
	if err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceGrantSelectSQL()+where+` ORDER BY created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGrant, 0, query.PageSize)
	for rows.Next() {
		grant, err := scanResourceGrant(rows)
		if err != nil {
			return nil, 0, err
		}
		grant.Items, err = r.listResourceGrantItems(ctx, grant.GrantID)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, grant)
	}
	return items, total, rows.Err()
}

// ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图；价格始终由资源一日价乘以售卖天数派生。
func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	query = normalizeResourceShopItemsQuery(query)
	where, args := resourceShopItemsWhereSQL(query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM resource_shop_items si
		JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
		`+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+`
		ORDER BY si.sort_order ASC, si.shop_item_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize)
	for rows.Next() {
		item, err := scanResourceShopItem(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

// UpsertResourceShopItems 批量添加或重配售卖资源；同一个资源在商店只保留一个当前售卖配置。
func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeResourceShopItemsCommand(command)
	if err := validateResourceShopItemsCommand(command); err != nil {
		return nil, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	nowMs := time.Now().UnixMilli()
	resourceIDs := make([]int64, 0, len(command.Items))
	seen := make(map[int64]struct{}, len(command.Items))
	for _, item := range command.Items {
		if _, exists := seen[item.ResourceID]; exists {
			return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource")
		}
		seen[item.ResourceID] = struct{}{}
		resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
		if err != nil {
			return nil, err
		}
		if err := validateResourceShopResource(resource); err != nil {
			return nil, err
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO resource_shop_items (
			   app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order,
			   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
			   status = VALUES(status),
			   duration_days = VALUES(duration_days),
			   effective_from_ms = VALUES(effective_from_ms),
			   effective_to_ms = VALUES(effective_to_ms),
			   sort_order = VALUES(sort_order),
			   updated_by_user_id = VALUES(updated_by_user_id),
			   updated_at_ms = VALUES(updated_at_ms)`,
			command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder,
			command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
		); err != nil {
			return nil, err
		}
		resourceIDs = append(resourceIDs, item.ResourceID)
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs)
}

func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	if command.ShopItemID <= 0 {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	defer func() { _ = tx.Rollback() }()

	item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	if command.Status == resourcedomain.StatusActive {
		if err := validateResourceShopResource(item.Resource); err != nil {
			return resourcedomain.ResourceShopItem{}, err
		}
	}
	nowMs := time.Now().UnixMilli()
	result, err := tx.ExecContext(ctx, `
		UPDATE resource_shop_items
		SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND shop_item_id = ?`,
		command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID,
	)
	if err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	} else if affected == 0 {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	return r.getResourceShopItem(ctx, command.ShopItemID)
}

// PurchaseResourceShopItem 是 App 道具商店的唯一购买入口；扣金币、写订单和发资源权益必须共享同一事务。
func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command.CommandID = strings.TrimSpace(command.CommandID)

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := resourceShopPurchaseRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists {
		if err != nil {
			return resourcedomain.ResourceShopPurchaseReceipt{}, err
		}
		return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
	}

	nowMs := time.Now().UnixMilli()
	item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := validatePurchasableResourceShopItem(item, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if account.AvailableAmount < item.CoinPrice {
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID)
	grantID := resourceGrantID(command.AppCode, command.CommandID)
	durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
	coinBalanceAfter := account.AvailableAmount - item.CoinPrice
	metadata := map[string]any{
		"app_code":          command.AppCode,
		"order_id":          orderID,
		"user_id":           command.UserID,
		"shop_item_id":      item.ShopItemID,
		"resource_id":       item.ResourceID,
		"resource_code":     item.Resource.ResourceCode,
		"resource_type":     item.Resource.ResourceType,
		"duration_days":     item.DurationDays,
		"duration_ms":       durationMS,
		"price_type":        item.PriceType,
		"coin_spent":        item.CoinPrice,
		"balance_after":     coinBalanceAfter,
		"resource_grant_id": grantID,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -item.CoinPrice,
		FrozenDelta:    0,
		AvailableAfter: coinBalanceAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if grantItem.EntitlementID == "" {
		// 道具商店只出售权益类资源；如果配置成钱包入账资源，必须阻断而不是产生无背包道具的订单。
		return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement")
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO resource_shop_purchase_orders (
		   app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin,
		   status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`,
		command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice,
		transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs,
	); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
		resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs),
		{
			EventID:       eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset),
			EventType:     "ResourceShopItemPurchased",
			TransactionID: transactionID,
			CommandID:     command.CommandID,
			UserID:        command.UserID,
			AssetType:     resourceOutboxAsset,
			Payload:       metadata,
			CreatedAtMS:   nowMs,
		},
	}); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}

	return resourcedomain.ResourceShopPurchaseReceipt{
		OrderID:       orderID,
		TransactionID: transactionID,
		ShopItem:      item,
		Resource:      resource,
		Balance: ledger.AssetBalance{
			AppCode:         command.AppCode,
			UserID:          command.UserID,
			AssetType:       ledger.AssetCoin,
			AvailableAmount: coinBalanceAfter,
			FrozenAmount:    account.FrozenAmount,
			Version:         account.Version + 1,
		},
		CoinSpent: item.CoinPrice,
		GrantID:   grantID,
	}, nil
}

func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
	if r == nil || r.db == nil {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID)
	grant, err := scanResourceGrant(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found")
	}
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grant.Items, err = r.listResourceGrantItems(ctx, grantID)
	return grant, err
}

func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand, update bool) (resourcedomain.GiftConfig, error) {
	if r == nil || r.db == nil {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	command = normalizeGiftConfigCommand(command)
	if command.ResourceID <= 0 {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config command is incomplete")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if resource.ResourceType != resourcedomain.TypeGift {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource")
	}
	command = applyResourcePricingToGiftCommand(command, resource)
	if resourcedomain.NormalizePriceType(resource.PriceType) == "" && command.CoinPrice <= 0 {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift price is invalid")
	}
	if err := validateGiftConfigCommand(command); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled")
	}
	if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if ok, err := r.giftTypeConfigExistsTx(ctx, tx, command.GiftTypeCode); err != nil {
		return resourcedomain.GiftConfig{}, err
	} else if !ok {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift type is not configured")
	}
	nowMs := time.Now().UnixMilli()
	// CP 关系类型存放在 presentation_json 内，保存前统一合并，避免 gift_configs 为单个业务展示字段扩表。
	presentation := giftPresentationWithCPRelationType(command.PresentationJSON, command.CPRelationType)
	effectTypesJSON, err := json.Marshal(command.EffectTypes)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if !update {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO gift_configs (
			   app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
			   gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
			   created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
			command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
			command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
		); err != nil {
			return resourcedomain.GiftConfig{}, err
		}
	} else {
		result, err := tx.ExecContext(ctx, `
			UPDATE gift_configs
			SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?,
			    gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?,
			    updated_by_user_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ?`,
			command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
			command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
			command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
		)
		if err != nil {
			return resourcedomain.GiftConfig{}, err
		}
		if affected, err := result.RowsAffected(); err != nil {
			return resourcedomain.GiftConfig{}, err
		} else if affected == 0 {
			return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
		}
	}
	if err := r.upsertGiftPriceTx(ctx, tx, command, nowMs); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := r.replaceGiftConfigRegionsTx(ctx, tx, command.GiftID, command.RegionIDs, nowMs); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:config:%d", command.GiftID, nowMs), 0, command.ResourceID, command, nowMs),
	}); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return r.getGiftConfig(ctx, command.GiftID)
}

func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) {
	if quantity <= 0 {
		return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive")
	}
	snapshot, err := json.Marshal(resource)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	item := resourcedomain.ResourceGrantItem{
		GrantID:              grantID,
		ResourceID:           resource.ResourceID,
		ResourceSnapshotJSON: string(snapshot),
		Quantity:             quantity,
		DurationMS:           durationMS,
		CreatedAtMS:          nowMs,
	}
	if resourcedomain.IsWalletCredit(resource) {
		assetType := resourcedomain.NormalizeWalletAssetType(resource.WalletAssetType)
		amount, err := checkedMul(resource.WalletAssetAmount, quantity)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		walletCommandID := fmt.Sprintf("resource_grant:%s:%d", commandID, resource.ResourceID)
		transactionID := transactionID(appcode.FromContext(ctx), walletCommandID)
		metadata := map[string]any{
			"app_code":        appcode.FromContext(ctx),
			"grant_id":        grantID,
			"command_id":      commandID,
			"target_user_id":  targetUserID,
			"resource_id":     resource.ResourceID,
			"resource_code":   resource.ResourceCode,
			"resource_type":   resource.ResourceType,
			"quantity":        quantity,
			"wallet_amount":   amount,
			"wallet_asset":    assetType,
			"operator_reason": "resource grant",
		}
		if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, resourceWalletCreditHash(grantID, resource.ResourceID, quantity), grantID, metadata, nowMs); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		availableAfter := account.AvailableAmount + amount
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:  transactionID,
			UserID:         targetUserID,
			AssetType:      assetType,
			AvailableDelta: amount,
			FrozenDelta:    0,
			AvailableAfter: availableAfter,
			FrozenAfter:    account.FrozenAmount,
			CreatedAtMS:    nowMs,
		}); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
			balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
		}); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		item.ResultType = resourcedomain.ResultWalletCredit
		item.WalletTransactionID = transactionID
	} else {
		entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs)
		if err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
		item.ResultType = resourcedomain.ResultEntitlement
		item.EntitlementID = entitlementID
		if err := r.autoEquipGrantedEntitlementTx(ctx, tx, targetUserID, resource, entitlementID, nowMs); err != nil {
			return resourcedomain.ResourceGrantItem{}, err
		}
	}
	inserted, err := r.insertResourceGrantItem(ctx, tx, item)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	return inserted, nil
}

func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, groupItem resourcedomain.ResourceGroupItem, nowMs int64) (resourcedomain.ResourceGrantItem, error) {
	assetType := resourcedomain.NormalizeWalletAssetType(groupItem.WalletAssetType)
	if !validGroupWalletAssetType(assetType) || groupItem.WalletAssetAmount <= 0 {
		return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
	}
	snapshot, err := json.Marshal(map[string]any{
		"group_item_id":       groupItem.GroupItemID,
		"group_id":            groupItem.GroupID,
		"item_type":           resourcedomain.GroupItemTypeWalletAsset,
		"wallet_asset_type":   assetType,
		"wallet_asset_amount": groupItem.WalletAssetAmount,
		"sort_order":          groupItem.SortOrder,
	})
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType)
	transactionID := transactionID(appcode.FromContext(ctx), walletCommandID)
	metadata := map[string]any{
		"app_code":        appcode.FromContext(ctx),
		"grant_id":        grantID,
		"command_id":      commandID,
		"target_user_id":  targetUserID,
		"group_id":        groupItem.GroupID,
		"group_item_id":   groupItem.GroupItemID,
		"item_type":       resourcedomain.GroupItemTypeWalletAsset,
		"wallet_amount":   groupItem.WalletAssetAmount,
		"wallet_asset":    assetType,
		"operator_reason": "resource group grant",
	}
	if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, groupItem.WalletAssetAmount, 0, nowMs); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	availableAfter := account.AvailableAmount + groupItem.WalletAssetAmount
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         targetUserID,
		AssetType:      assetType,
		AvailableDelta: groupItem.WalletAssetAmount,
		FrozenDelta:    0,
		AvailableAfter: availableAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
	}); err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	inserted, err := r.insertResourceGrantItem(ctx, tx, resourcedomain.ResourceGrantItem{
		GrantID:              grantID,
		ResourceID:           0,
		ResourceSnapshotJSON: string(snapshot),
		Quantity:             groupItem.WalletAssetAmount,
		DurationMS:           0,
		ResultType:           resourcedomain.ResultWalletCredit,
		WalletTransactionID:  transactionID,
		CreatedAtMS:          nowMs,
	})
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	return inserted, nil
}

func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (string, error) {
	expiresAt := int64(0)
	if durationMS > 0 {
		if durationMS > math.MaxInt64-nowMs {
			return "", xerr.New(xerr.InvalidArgument, "duration overflow")
		}
		expiresAt = nowMs + durationMS
	}
	strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy)
	if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag {
		existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, nowMs)
		if err != nil {
			return "", err
		}
		if exists {
			newQuantity := existing.Quantity
			newRemaining := existing.RemainingQuantity
			newExpiresAt := existing.ExpiresAtMS
			switch strategy {
			case resourcedomain.GrantStrategyExtendExpiry:
				newRemaining += quantity
				newQuantity += quantity
				if durationMS > 0 {
					base := max(existing.ExpiresAtMS, nowMs)
					if durationMS > math.MaxInt64-base {
						return "", xerr.New(xerr.InvalidArgument, "duration overflow")
					}
					newExpiresAt = base + durationMS
				} else {
					newExpiresAt = 0
				}
			case resourcedomain.GrantStrategyIncreaseQuantity:
				newQuantity += quantity
				newRemaining += quantity
			case resourcedomain.GrantStrategySetActiveFlag:
				newQuantity = 1
				newRemaining = 1
				newExpiresAt = expiresAt
			}
			if _, err := tx.ExecContext(ctx, `
				UPDATE user_resource_entitlements
				SET status = 'active', quantity = ?, remaining_quantity = ?, expires_at_ms = ?,
				    source_grant_id = ?, updated_at_ms = ?
				WHERE app_code = ? AND entitlement_id = ?`,
				newQuantity, newRemaining, newExpiresAt, grantID, nowMs, appcode.FromContext(ctx), existing.EntitlementID,
			); err != nil {
				return "", err
			}
			return existing.EntitlementID, nil
		}
	}
	entitlementID := entitlementID(appcode.FromContext(ctx), grantID, resource.ResourceID, nowMs)
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_resource_entitlements (
		   app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
		   effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID, nowMs, nowMs,
	); err != nil {
		return "", err
	}
	return entitlementID, nil
}

func (r *Repository) autoEquipGrantedEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, entitlementID string, nowMs int64) error {
	if !isAutoEquippedOnGrantResourceType(resource.ResourceType) || strings.TrimSpace(entitlementID) == "" {
		return nil
	}
	entitlement, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
	if err != nil {
		return err
	}
	if err := r.equipUserResourceEntitlementTx(ctx, tx, userID, entitlement, nowMs); err != nil {
		return err
	}
	entitlement.Equipped = true
	// 徽章和麦位声波都属于“拿到即可默认展示”的装扮资源；写 equipment 必须和授权在同一事务内完成，
	// 否则发放成功但佩戴失败会让背包、麦位表现和后续投影读到不一致的状态。
	// 单独发 UserResourceChanged 事件，让只监听外观变化的投影不用重新理解 ResourceGranted 的来源差异。
	eventCommandID := fmt.Sprintf("auto_equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano())
	return r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("UserResourceChanged", eventCommandID, userID, resource.ResourceID, map[string]any{
			"action":        "auto_equip",
			"app_code":      appcode.FromContext(ctx),
			"user_id":       userID,
			"resource_id":   resource.ResourceID,
			"resource_type": resource.ResourceType,
			"entitlement":   entitlement,
			"updated_at_ms": nowMs,
			"event_command": eventCommandID,
		}, nowMs),
	})
}

func (r *Repository) equipUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement, nowMs int64) error {
	resourceType := resourcedomain.NormalizeResourceType(entitlement.Resource.ResourceType)
	if !isEquipableResourceType(resourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
	}
	if !allowsMultipleEquippedResources(resourceType) {
		// schema 已经允许同类型多条 equipment；非徽章类型仍要在写入侧清掉旧指针，保持头像框/资料卡/座驾等单选语义。
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM user_resource_equipment
			WHERE app_code = ? AND user_id = ? AND resource_type = ? AND entitlement_id <> ?`,
			appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID,
		); err != nil {
			return err
		}
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_resource_equipment (
		   app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		   resource_id = VALUES(resource_id),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), userID, resourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs,
	)
	return err
}

func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error {
	if strings.TrimSpace(entitlementID) == "" {
		return nil
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE user_resource_entitlements
		SET expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND entitlement_id = ?`,
		expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID,
	)
	return err
}

func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.created_at_ms, e.updated_at_ms,
		       FALSE AS equipped,
		       `+resourceColumnsWithAlias("r")+`
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active'
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC
		LIMIT 1
		FOR UPDATE`,
		appcode.FromContext(ctx), userID, resourceID, nowMs, nowMs,
	)
	item, err := scanUserResourceEntitlement(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, false, nil
	}
	return item, err == nil, err
}

func (r *Repository) queryEquipableEntitlementForUpdate(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, error) {
	where := `
		WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active'
		  AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?)
		  AND e.remaining_quantity > 0 AND r.status = 'active'`
	args := []any{appcode.FromContext(ctx), command.UserID, command.ResourceID, nowMs, nowMs}
	if command.EntitlementID != "" {
		where += ` AND e.entitlement_id = ?`
		args = append(args, command.EntitlementID)
	}
	query := userResourceSelectSQL() + where + `
		ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC
		LIMIT 1
		FOR UPDATE`
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, query, args...))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) {
	result, err := tx.ExecContext(ctx, `
		INSERT INTO resource_grant_items (
		   app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms,
		   result_type, wallet_transaction_id, entitlement_id, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), item.GrantID, item.ResourceID, item.ResourceSnapshotJSON,
		item.Quantity, item.DurationMS, item.ResultType, item.WalletTransactionID, item.EntitlementID, item.CreatedAtMS,
	)
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return resourcedomain.ResourceGrantItem{}, err
	}
	item.GrantItemID = id
	return item, nil
}

func (r *Repository) insertResourceGrant(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, grantSource string, subjectType string, subjectID string, requestHash string, groupSnapshotJSON string, reason string, operatorUserID int64, nowMs int64) error {
	var snapshot any
	if strings.TrimSpace(groupSnapshotJSON) == "" {
		snapshot = nil
	} else {
		snapshot = groupSnapshotJSON
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO resource_grants (
		   app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
		   grant_subject_id, status, request_hash, group_snapshot_json, reason,
		   operator_user_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), grantID, commandID, targetUserID, grantSource, subjectType,
		subjectID, resourcedomain.GrantStatusDone, requestHash, snapshot, reason, operatorUserID, nowMs, nowMs,
	)
	return err
}

func (r *Repository) lookupResourceGrant(ctx context.Context, tx *sql.Tx, commandID string, requestHash string) (string, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT grant_id, request_hash FROM resource_grants WHERE app_code = ? AND command_id = ? FOR UPDATE`,
		appcode.FromContext(ctx), commandID,
	)
	var grantID string
	var storedHash string
	if err := row.Scan(&grantID, &storedHash); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return "", false, nil
		}
		return "", false, err
	}
	if storedHash != requestHash {
		return "", true, xerr.New(xerr.LedgerConflict, "resource grant idempotency conflict")
	}
	return grantID, true, nil
}

func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) {
	row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID)
	grant, err := scanResourceGrant(row)
	if err != nil {
		return resourcedomain.ResourceGrant{}, err
	}
	grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grantID)
	return grant, err
}

func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID)
}

func (r *Repository) listResourceGrantItemsTx(ctx context.Context, tx *sql.Tx, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	return r.listResourceGrantItemsWithQuery(ctx, tx, grantID)
}

func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querier interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT grant_item_id, grant_id, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'),
		       quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms
		FROM resource_grant_items
		WHERE app_code = ? AND grant_id = ?
		ORDER BY grant_item_id ASC`,
		appcode.FromContext(ctx), grantID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGrantItem, 0)
	for rows.Next() {
		var item resourcedomain.ResourceGrantItem
		if err := rows.Scan(&item.GrantItemID, &item.GrantID, &item.ResourceID, &item.ResourceSnapshotJSON, &item.Quantity, &item.DurationMS, &item.ResultType, &item.WalletTransactionID, &item.EntitlementID, &item.CreatedAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error {
	legacyHeatValue := command.CoinPrice
	_, err := tx.ExecContext(ctx, `
		INSERT INTO wallet_gift_prices (
		   app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount,
		   heat_value, effective_at_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		   status = VALUES(status),
		   charge_asset_type = VALUES(charge_asset_type),
		   coin_price = VALUES(coin_price),
		   gift_point_amount = VALUES(gift_point_amount),
		   heat_value = VALUES(heat_value),
		   effective_at_ms = VALUES(effective_at_ms),
		   updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice,
		command.GiftPointAmount, legacyHeatValue, command.EffectiveAtMS, nowMs, nowMs,
	)
	return err
}

func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) {
	row := querier.QueryRowContext(ctx,
		`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
		 FROM wallet_gift_prices
		 WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
		 ORDER BY effective_at_ms DESC, price_version DESC
		 LIMIT 1`,
		appcode.FromContext(ctx), giftID, nowMs,
	)
	var price giftPrice
	if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
		}
		return giftPrice{}, err
	}
	price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
	return price, nil
}

func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) {
	row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID)
	gift, err := scanGiftConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli())
	if priceErr == nil {
		gift.PriceVersion = price.PriceVersion
		gift.ChargeAssetType = price.ChargeAssetType
		gift.CoinPrice = price.CoinPrice
		// gift_point_amount 是历史列，后台和送礼主链路只暴露真实 COIN 价格。
		gift.GiftPointAmount = 0
		// heat_value 是旧配置列，后台不再读写；对兼容调用方只回真实价格，房间贡献也按真实扣费结算。
		gift.HeatValue = price.CoinPrice
	}
	gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return gift, nil
}

func (r *Repository) getGiftConfigForUpdateTx(ctx context.Context, tx *sql.Tx, giftID string) (resourcedomain.GiftConfig, error) {
	row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ? FOR UPDATE`, appcode.FromContext(ctx), giftID)
	gift, err := scanGiftConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found")
	}
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	// 删除前快照需要带当前有效价格和投放区域，便于审计和 outbox 消费者判断被删除的是哪条配置。
	price, priceErr := r.latestGiftPrice(ctx, tx, gift.GiftID, time.Now().UnixMilli())
	if priceErr == nil {
		gift.PriceVersion = price.PriceVersion
		gift.ChargeAssetType = price.ChargeAssetType
		gift.CoinPrice = price.CoinPrice
		gift.GiftPointAmount = 0
		gift.HeatValue = price.CoinPrice
	}
	gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	return gift, nil
}

func (r *Repository) getGiftTypeConfigTx(ctx context.Context, tx *sql.Tx, typeCode string) (resourcedomain.GiftTypeConfig, error) {
	row := tx.QueryRowContext(ctx, giftTypeConfigSelectSQL()+`WHERE app_code = ? AND type_code = ?`, appcode.FromContext(ctx), typeCode)
	item, err := scanGiftTypeConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.NotFound, "gift type config not found")
	}
	return item, err
}

func (r *Repository) giftTypeConfigExistsTx(ctx context.Context, tx *sql.Tx, typeCode string) (bool, error) {
	var exists int
	err := tx.QueryRowContext(ctx, `
		SELECT 1
		FROM gift_type_configs
		WHERE app_code = ? AND type_code = ?
		LIMIT 1`, appcode.FromContext(ctx), typeCode).Scan(&exists)
	if errors.Is(err, sql.ErrNoRows) {
		return false, nil
	}
	return err == nil, err
}

func (r *Repository) ensureDefaultGiftTypeConfigs(ctx context.Context, execer sqlExecer) error {
	nowMs := time.Now().UnixMilli()
	for _, item := range resourcedomain.DefaultGiftTypeConfigs(appcode.FromContext(ctx)) {
		if _, err := execer.ExecContext(ctx, `
			INSERT IGNORE INTO gift_type_configs (
				app_code, type_code, name, tab_key, status, sort_order,
				created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
			item.AppCode, item.TypeCode, item.Name, item.TabKey, item.Status, item.SortOrder, nowMs, nowMs,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) {
	nowMs := time.Now().UnixMilli()
	row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+`
		WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
		  AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
		  AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)
		  AND EXISTS (
		    SELECT 1
		    FROM gift_config_regions gcr
		    WHERE gcr.app_code = gc.app_code
		      AND gcr.gift_id = gc.gift_id
		      AND (gcr.region_id = 0 OR gcr.region_id = ?)
		  )
		FOR UPDATE`,
		appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID,
	)
	gift, err := scanGiftConfig(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config is not active")
	}
	if err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID)
	return gift, err
}

func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) {
	row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID)
	resource, err := scanResource(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found")
	}
	return resource, err
}

func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) {
	return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false)
}

func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
	return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock)
}

func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) {
	query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID)
	item, err := scanResourceShopItem(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found")
	}
	return item, err
}

func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
	item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
		userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`,
		appcode.FromContext(ctx), entitlementID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
	}
	return item, err
}

func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) {
	var orderID string
	var shopItemID int64
	var priceCoin int64
	var grantID string
	var entitlementID string
	err := tx.QueryRowContext(ctx, `
		SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id
		FROM resource_shop_purchase_orders
		WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`,
		appcode.FromContext(ctx), transactionID, userID,
	).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin)
	if err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	}
	if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil {
		return resourcedomain.ResourceShopPurchaseReceipt{}, err
	} else if exists {
		balance.Version = account.Version
	}
	return resourcedomain.ResourceShopPurchaseReceipt{
		OrderID:       orderID,
		TransactionID: transactionID,
		ShopItem:      item,
		Resource:      resource,
		Balance:       balance,
		CoinSpent:     priceCoin,
		GrantID:       grantID,
	}, nil
}

func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) {
	resourceIDs = compactPositiveInt64s(resourceIDs)
	if len(resourceIDs) == 0 {
		return []resourcedomain.ResourceShopItem{}, nil
	}
	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...)
	rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+`
		WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`)
		ORDER BY si.sort_order ASC, si.shop_item_id DESC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs))
	for rows.Next() {
		item, err := scanResourceShopItem(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) {
	query := `
		SELECT app_code, group_id, group_code, name, status, description, sort_order,
		       created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		FROM resource_groups
		WHERE app_code = ? AND group_id = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), groupID)
	group, err := scanResourceGroup(row)
	if errors.Is(err, sql.ErrNoRows) {
		return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found")
	}
	return group, err
}

func (r *Repository) listResourceGroupItems(ctx context.Context, groupID int64) ([]resourcedomain.ResourceGroupItem, error) {
	return r.listResourceGroupItemsWithQuery(ctx, r.db, groupID)
}

func (r *Repository) listResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, lock bool) ([]resourcedomain.ResourceGroupItem, error) {
	if lock {
		if err := r.lockResourceGroupItemRows(ctx, tx, groupID); err != nil {
			return nil, err
		}
	}
	items, err := r.listResourceGroupItemsWithQuery(ctx, tx, groupID)
	if err != nil || !lock {
		return items, err
	}
	for index := range items {
		if resourcedomain.NormalizeGroupItemType(items[index].ItemType) != resourcedomain.GroupItemTypeResource {
			continue
		}
		resource, err := r.getResourceForUpdate(ctx, tx, items[index].ResourceID)
		if err != nil {
			return nil, err
		}
		items[index].Resource = resource
	}
	return items, nil
}

func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querier interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, groupID int64) ([]resourcedomain.ResourceGroupItem, error) {
	query := `
		SELECT i.group_item_id, i.group_id, i.item_type, i.resource_id,
		       i.wallet_asset_type, i.wallet_asset_amount,
		       ` + nullableResourceColumnsWithAlias("r") + `,
		       i.quantity, i.duration_ms, i.sort_order, i.created_at_ms, i.updated_at_ms
		FROM resource_group_items i
		LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
		WHERE i.app_code = ? AND i.group_id = ?
		ORDER BY i.sort_order ASC, i.group_item_id ASC`
	rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]resourcedomain.ResourceGroupItem, 0)
	for rows.Next() {
		item, err := scanResourceGroupItem(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx, groupID int64) error {
	rows, err := tx.QueryContext(ctx, `
		SELECT group_item_id
		FROM resource_group_items
		WHERE app_code = ? AND group_id = ?
		ORDER BY sort_order ASC, group_item_id ASC
		FOR UPDATE`,
		appcode.FromContext(ctx), groupID,
	)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return err
		}
	}
	return rows.Err()
}

func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, items []resourcedomain.ResourceGroupItemInput, requireGrantable bool, nowMs int64) error {
	seenResources := make(map[int64]struct{}, len(items))
	seenWalletAssets := make(map[string]struct{}, len(items))
	if _, err := tx.ExecContext(ctx, `DELETE FROM resource_group_items WHERE app_code = ? AND group_id = ?`, appcode.FromContext(ctx), groupID); err != nil {
		return err
	}
	for _, item := range items {
		item = normalizeResourceGroupItemInput(item)
		switch item.ItemType {
		case resourcedomain.GroupItemTypeResource:
			if item.ResourceID <= 0 || item.Quantity <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group item is invalid")
			}
			if _, ok := seenResources[item.ResourceID]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource")
			}
			seenResources[item.ResourceID] = struct{}{}
			resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID)
			if err != nil {
				return err
			}
			if requireGrantable {
				if err := validateActiveResourceGroupResource(resource); err != nil {
					return err
				}
			}
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO resource_group_items (
				   app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
				   quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				appcode.FromContext(ctx), groupID, item.ItemType, item.ResourceID, "", int64(0),
				item.Quantity, item.DurationMS, item.SortOrder, nowMs, nowMs,
			); err != nil {
				return err
			}
		case resourcedomain.GroupItemTypeWalletAsset:
			if !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
			}
			if _, ok := seenWalletAssets[item.WalletAssetType]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset")
			}
			seenWalletAssets[item.WalletAssetType] = struct{}{}
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO resource_group_items (
				   app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
				   quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				appcode.FromContext(ctx), groupID, item.ItemType, int64(0), item.WalletAssetType, item.WalletAssetAmount,
				int64(1), int64(0), item.SortOrder, nowMs, nowMs,
			); err != nil {
				return err
			}
		default:
			return xerr.New(xerr.InvalidArgument, "resource group item type is invalid")
		}
	}
	return nil
}

func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error {
	if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil {
		return err
	}
	for _, regionID := range regionIDs {
		if regionID < 0 {
			return xerr.New(xerr.InvalidArgument, "gift region is invalid")
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO gift_config_regions (
			   app_code, gift_id, region_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT region_id
		FROM gift_config_regions
		WHERE app_code = ? AND gift_id = ?
		ORDER BY region_id ASC`,
		appcode.FromContext(ctx), giftID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	regionIDs := make([]int64, 0)
	for rows.Next() {
		var regionID int64
		if err := rows.Scan(&regionID); err != nil {
			return nil, err
		}
		regionIDs = append(regionIDs, regionID)
	}
	return regionIDs, rows.Err()
}

func (r *Repository) countResourceRows(ctx context.Context, table string, where string, args ...any) (int64, error) {
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` `+where, args...).Scan(&total); err != nil {
		return 0, err
	}
	return total, nil
}

func scanResource(scanner scanTarget) (resourcedomain.Resource, error) {
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.Resource{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	return resource, nil
}

func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) {
	var item resourcedomain.ResourceShopItem
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.AppCode,
		&item.ShopItemID,
		&item.ResourceID,
		&item.Status,
		&item.DurationDays,
		&item.PriceType,
		&item.CoinPrice,
		&item.EffectiveFromMS,
		&item.EffectiveToMS,
		&item.SortOrder,
		&item.CreatedByUserID,
		&item.UpdatedByUserID,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.ResourceShopItem{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) {
	var group resourcedomain.ResourceGroup
	err := scanner.Scan(
		&group.AppCode,
		&group.GroupID,
		&group.GroupCode,
		&group.Name,
		&group.Status,
		&group.Description,
		&group.SortOrder,
		&group.CreatedByUserID,
		&group.UpdatedByUserID,
		&group.CreatedAtMS,
		&group.UpdatedAtMS,
	)
	return group, err
}

func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem, error) {
	var item resourcedomain.ResourceGroupItem
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.GroupItemID,
		&item.GroupID,
		&item.ItemType,
		&item.ResourceID,
		&item.WalletAssetType,
		&item.WalletAssetAmount,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
		&item.Quantity,
		&item.DurationMS,
		&item.SortOrder,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
	); err != nil {
		return resourcedomain.ResourceGroupItem{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
	var gift resourcedomain.GiftConfig
	var resource resourcedomain.Resource
	var scopesJSON string
	var effectTypesJSON string
	if err := scanner.Scan(
		&gift.AppCode,
		&gift.GiftID,
		&gift.ResourceID,
		&gift.Status,
		&gift.Name,
		&gift.SortOrder,
		&gift.PresentationJSON,
		&gift.GiftTypeCode,
		&gift.EffectiveFromMS,
		&gift.EffectiveToMS,
		&effectTypesJSON,
		&gift.CreatedByUserID,
		&gift.UpdatedByUserID,
		&gift.CreatedAtMS,
		&gift.UpdatedAtMS,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.GiftConfig{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	gift.Resource = resource
	gift.EffectTypes = parseStringArray(effectTypesJSON)
	// CPRelationType 是展示 JSON 的结构化投影，gift panel 和送礼回执不再让调用方自己解析 JSON。
	gift.CPRelationType = cpRelationTypeFromPresentationJSON(gift.PresentationJSON)
	return gift, nil
}

func scanGiftTypeConfig(scanner scanTarget) (resourcedomain.GiftTypeConfig, error) {
	var item resourcedomain.GiftTypeConfig
	err := scanner.Scan(
		&item.AppCode,
		&item.TypeCode,
		&item.Name,
		&item.TabKey,
		&item.Status,
		&item.SortOrder,
		&item.CreatedByUserID,
		&item.UpdatedByUserID,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
	)
	return item, err
}

func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) {
	var item resourcedomain.UserResourceEntitlement
	var resource resourcedomain.Resource
	var scopesJSON string
	if err := scanner.Scan(
		&item.AppCode,
		&item.EntitlementID,
		&item.UserID,
		&item.ResourceID,
		&item.Status,
		&item.Quantity,
		&item.RemainingQuantity,
		&item.EffectiveAtMS,
		&item.ExpiresAtMS,
		&item.SourceGrantID,
		&item.CreatedAtMS,
		&item.UpdatedAtMS,
		&item.Equipped,
		&resource.AppCode,
		&resource.ResourceID,
		&resource.ResourceCode,
		&resource.ResourceType,
		&resource.Name,
		&resource.Status,
		&resource.Grantable,
		&resource.ManagerGrantEnabled,
		&resource.GrantStrategy,
		&resource.WalletAssetType,
		&resource.WalletAssetAmount,
		&resource.PriceType,
		&resource.CoinPrice,
		&resource.GiftPointAmount,
		&scopesJSON,
		&resource.AssetURL,
		&resource.PreviewURL,
		&resource.AnimationURL,
		&resource.MetadataJSON,
		&resource.SortOrder,
		&resource.CreatedByUserID,
		&resource.UpdatedByUserID,
		&resource.CreatedAtMS,
		&resource.UpdatedAtMS,
	); err != nil {
		return resourcedomain.UserResourceEntitlement{}, err
	}
	resource.UsageScopes = parseStringArray(scopesJSON)
	item.Resource = resource
	return item, nil
}

func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error) {
	var grant resourcedomain.ResourceGrant
	err := scanner.Scan(
		&grant.AppCode,
		&grant.GrantID,
		&grant.CommandID,
		&grant.TargetUserID,
		&grant.GrantSource,
		&grant.GrantSubjectType,
		&grant.GrantSubjectID,
		&grant.Status,
		&grant.Reason,
		&grant.OperatorUserID,
		&grant.CreatedAtMS,
		&grant.UpdatedAtMS,
	)
	return grant, err
}

func resourceSelectSQL() string {
	return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources`
}

func resourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` +
		prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` +
		prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` +
		prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms`
}

func nullableResourceColumnsWithAlias(alias string) string {
	prefix := ""
	if alias != "" {
		prefix = alias + "."
	}
	return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` +
		`COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` +
		`COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` +
		`COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` +
		`COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` +
		`COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` +
		`COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` +
		`COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)`
}

func giftConfigSelectSQL() string {
	return `
		SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
		       COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
		       COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
		       COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'),
		       gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM gift_configs gc
		JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id
		`
}

func giftTypeConfigSelectSQL() string {
	return `
		SELECT app_code, type_code, name, tab_key, status, sort_order,
		       created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
		FROM gift_type_configs
		`
}

func resourceShopItemSelectSQL() string {
	return `
		SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days,
		       r.price_type,
		       CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price,
		       si.effective_from_ms, si.effective_to_ms, si.sort_order,
		       si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms,
		       ` + resourceColumnsWithAlias("r") + `
		FROM resource_shop_items si
		JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id
		`
}

func userResourceSelectSQL() string {
	return `
		SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
		       e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
		       e.source_grant_id, e.created_at_ms, e.updated_at_ms,
		       CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped,
		       ` + resourceColumnsWithAlias("r") + `
		FROM user_resource_entitlements e
		JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
		LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code
		 AND eq.user_id = e.user_id
		 AND eq.resource_type = r.resource_type
		 AND eq.entitlement_id = e.entitlement_id
		`
}

func resourceGrantSelectSQL() string {
	return `
		SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
		       grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms
		FROM resource_grants
		`
}

func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) {
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.ManagerGrantOnly {
		where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE`
	} else if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (resource_code LIKE ? OR name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func resourceGroupWhereSQL(query resourcedomain.ListResourceGroupsQuery) (string, []any) {
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (group_code LIKE ? OR name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) {
	where := `WHERE gc.app_code = ?`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift'
			AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?)
			AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)`
		nowMs := time.Now().UnixMilli()
		args = append(args, nowMs, nowMs)
	} else if query.Status != "" {
		where += ` AND gc.status = ?`
		args = append(args, query.Status)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)`
		args = append(args, like, like, like)
	}
	if query.GiftTypeCode != "" {
		where += ` AND gc.gift_type_code = ?`
		args = append(args, query.GiftTypeCode)
	}
	if query.FilterRegion {
		where += ` AND EXISTS (
			SELECT 1
			FROM gift_config_regions gcr
			WHERE gcr.app_code = gc.app_code
			  AND gcr.gift_id = gc.gift_id
			  AND (gcr.region_id = 0 OR gcr.region_id = ?)
		)`
		args = append(args, query.RegionID)
	}
	return where, args
}

func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (string, []any) {
	where := `WHERE app_code = ?`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		where += ` AND status = 'active'`
	} else if query.Status != "" {
		where += ` AND status = ?`
		args = append(args, query.Status)
	}
	return where, args
}

func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) {
	where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')`
	args := []any{query.AppCode}
	if query.ActiveOnly {
		nowMs := time.Now().UnixMilli()
		where += ` AND si.status = 'active' AND r.status = 'active'
			AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?)
			AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)`
		args = append(args, nowMs, nowMs)
	} else if query.Status != "" {
		where += ` AND si.status = ?`
		args = append(args, query.Status)
	}
	if query.ResourceType != "" {
		where += ` AND r.resource_type = ?`
		args = append(args, query.ResourceType)
	}
	if query.Keyword != "" {
		like := "%" + query.Keyword + "%"
		where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)`
		args = append(args, like, like)
	}
	return where, args
}

func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery {
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) {
		query.ResourceType = "__invalid__"
	}
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeResourceGroupListQuery(query resourcedomain.ListResourceGroupsQuery) resourcedomain.ListResourceGroupsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	// 列表查询的 gift_type_code 是可选过滤条件；空值必须保持为空，避免礼物面板加载全部礼物时被默认收窄到 normal。
	if strings.TrimSpace(query.GiftTypeCode) != "" {
		query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode)
	}
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeGiftTypeConfigListQuery(query resourcedomain.ListGiftTypeConfigsQuery) resourcedomain.ListGiftTypeConfigsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	if query.Status != "" && !resourcedomain.ValidStatus(query.Status) {
		query.Status = "__invalid__"
	}
	return query
}

func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery {
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery {
	query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType)
	if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) {
		query.ResourceType = "__invalid__"
	}
	query.Status = strings.ToLower(strings.TrimSpace(query.Status))
	query.Keyword = strings.TrimSpace(query.Keyword)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	return query
}

func normalizePage(page int32, pageSize int32) (int32, int32) {
	if page < 1 {
		page = 1
	}
	if pageSize < 1 {
		pageSize = 20
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return page, pageSize
}

func resourceOffset(page int32, pageSize int32) int32 {
	return (page - 1) * pageSize
}

func placeholders(count int) string {
	if count <= 0 {
		return ""
	}
	items := make([]string, count)
	for index := range items {
		items[index] = "?"
	}
	return strings.Join(items, ",")
}

func int64AnyArgs(values []int64) []any {
	args := make([]any, 0, len(values))
	for _, value := range values {
		args = append(args, value)
	}
	return args
}

func stringAnyArgs(values []string) []any {
	args := make([]any, 0, len(values))
	for _, value := range values {
		args = append(args, value)
	}
	return args
}

func compactPositiveInt64s(values []int64) []int64 {
	seen := make(map[int64]struct{}, len(values))
	out := make([]int64, 0, len(values))
	for _, value := range values {
		if value <= 0 {
			continue
		}
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func normalizeEquipableResourceTypes(values []string) ([]string, error) {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		resourceType := resourcedomain.NormalizeResourceType(value)
		if resourceType == "" {
			// 空类型不限制查询范围，保持 batch API 可以表达“查全部可佩戴资源”。
			continue
		}
		if !resourcedomain.ValidResourceType(resourceType) || !isEquipableResourceType(resourceType) {
			return nil, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped")
		}
		if _, exists := seen[resourceType]; exists {
			// 去重后拼 IN (...)，避免无意义扩大 SQL 参数和破坏调用方顺序稳定性。
			continue
		}
		seen[resourceType] = struct{}{}
		out = append(out, resourceType)
	}
	return out, nil
}

func allowsMultipleEquippedResources(resourceType string) bool {
	return resourcedomain.NormalizeResourceType(resourceType) == resourcedomain.TypeBadge
}

func isAutoEquippedOnGrantResourceType(resourceType string) bool {
	switch resourcedomain.NormalizeResourceType(resourceType) {
	case resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation:
		return true
	default:
		return false
	}
}

func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error {
	userIDs = compactPositiveInt64s(userIDs)
	if r == nil || r.db == nil || len(userIDs) == 0 {
		return nil
	}
	// 清理函数和查询函数复用同一套类型校验，避免查询拒绝的类型被清理 SQL 接受。
	resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes)
	if err != nil {
		return err
	}
	args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...)
	args = append(args, nowMs, nowMs)
	typeFilter := ""
	if len(resourceTypes) > 0 {
		typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)`
		args = append(args, stringAnyArgs(resourceTypes)...)
	}
	// 删除条件覆盖三类 stale 指针：
	// 1. entitlement/resource 被删或不可用；
	// 2. entitlement 未生效、过期、剩余次数耗尽；
	// 3. resource 被下架。查询端随后只会看到仍可展示的佩戴。
	_, err = r.db.ExecContext(ctx, `
		DELETE eq
		FROM user_resource_equipment eq
		LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code
		 AND e.user_id = eq.user_id
		 AND e.resource_id = eq.resource_id
		 AND e.entitlement_id = eq.entitlement_id
		LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id
		WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`)
		  AND (
		    e.entitlement_id IS NULL
		    OR e.status <> 'active'
		    OR e.effective_at_ms > ?
		    OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?)
		    OR e.remaining_quantity <= 0
		    OR r.resource_id IS NULL
		    OR r.status <> 'active'
		  )`+typeFilter,
		args...,
	)
	return err
}

func mapResourceWriteError(err error) error {
	if isMySQLDuplicateError(err) {
		return xerr.New(xerr.Conflict, "resource_code already exists")
	}
	return err
}

func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
	command.ResourceCode = strings.TrimSpace(command.ResourceCode)
	command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
	command.Name = strings.TrimSpace(command.Name)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy)
	command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType)
	command.PriceType = resourcedomain.NormalizePriceType(command.PriceType)
	if command.PriceType == resourcedomain.PriceTypeFree {
		command.CoinPrice = 0
		command.GiftPointAmount = 0
	} else if command.PriceType == resourcedomain.PriceTypeCoin {
		// 资源价格只保留真实 COIN 价格；gift_point_amount 是历史字段，写入 0 兼容旧表结构。
		command.GiftPointAmount = 0
	}
	command.AssetURL = strings.TrimSpace(command.AssetURL)
	command.PreviewURL = strings.TrimSpace(command.PreviewURL)
	command.AnimationURL = strings.TrimSpace(command.AnimationURL)
	command.MetadataJSON = normalizeJSONObject(command.MetadataJSON)
	command.UsageScopes = normalizeStringList(command.UsageScopes)
	return command
}

func validateResourceCommand(command resourcedomain.ResourceCommand) error {
	if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 {
		return xerr.New(xerr.InvalidArgument, "resource command is incomplete")
	}
	if !resourcedomain.ValidResourceType(command.ResourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type is invalid")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) {
		return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid")
	}
	if command.ResourceType == resourcedomain.TypeCoin {
		if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 {
			return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN")
		}
	} else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 {
		return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit")
	}
	if command.PriceType == "" {
		if command.CoinPrice != 0 || command.GiftPointAmount != 0 {
			return xerr.New(xerr.InvalidArgument, "resource price type is required")
		}
		return nil
	}
	if !resourcedomain.ValidPriceType(command.PriceType) {
		return xerr.New(xerr.InvalidArgument, "resource price type is invalid")
	}
	if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource coin price is invalid")
	}
	if command.CoinPrice < 0 {
		return xerr.New(xerr.InvalidArgument, "resource price is invalid")
	}
	return nil
}

func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand {
	for index := range command.Items {
		command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status)
	}
	return command
}

func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error {
	if len(command.Items) == 0 || command.OperatorUserID < 0 {
		return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete")
	}
	for _, item := range command.Items {
		if item.ResourceID <= 0 {
			return xerr.New(xerr.InvalidArgument, "resource_id is required")
		}
		if !resourcedomain.ValidStatus(item.Status) {
			return xerr.New(xerr.InvalidArgument, "status is invalid")
		}
		if !resourcedomain.ValidShopDurationDays(item.DurationDays) {
			return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid")
		}
		if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) {
			return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid")
		}
	}
	return nil
}

func validateResourceShopResource(resource resourcedomain.Resource) error {
	if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) {
		return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop")
	}
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource coin price is required")
	}
	return nil
}

func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error {
	if item.Status != resourcedomain.StatusActive {
		return xerr.New(xerr.Conflict, "resource shop item is disabled")
	}
	if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs {
		return xerr.New(xerr.Conflict, "resource shop item is not effective")
	}
	if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs {
		return xerr.New(xerr.Conflict, "resource shop item is expired")
	}
	if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 {
		return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid")
	}
	return validateResourceShopResource(item.Resource)
}

func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
	command.GroupCode = strings.TrimSpace(command.GroupCode)
	command.Name = strings.TrimSpace(command.Name)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.Description = strings.TrimSpace(command.Description)
	for index := range command.Items {
		command.Items[index] = normalizeResourceGroupItemInput(command.Items[index])
	}
	return command
}

func validateResourceGroupCommand(command resourcedomain.ResourceGroupCommand, requireItems bool) error {
	if command.GroupCode == "" || command.Name == "" {
		return xerr.New(xerr.InvalidArgument, "resource group command is incomplete")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if requireItems && len(command.Items) == 0 {
		return xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	seenResources := make(map[int64]struct{}, len(command.Items))
	seenWalletAssets := make(map[string]struct{}, len(command.Items))
	for _, item := range command.Items {
		if !resourcedomain.ValidGroupItemType(item.ItemType) {
			return xerr.New(xerr.InvalidArgument, "resource group item type is invalid")
		}
		switch item.ItemType {
		case resourcedomain.GroupItemTypeResource:
			if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 {
				return xerr.New(xerr.InvalidArgument, "resource group item is invalid")
			}
			if _, ok := seenResources[item.ResourceID]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource")
			}
			seenResources[item.ResourceID] = struct{}{}
		case resourcedomain.GroupItemTypeWalletAsset:
			if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
				return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid")
			}
			if _, ok := seenWalletAssets[item.WalletAssetType]; ok {
				return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset")
			}
			seenWalletAssets[item.WalletAssetType] = struct{}{}
		}
	}
	return nil
}

func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) resourcedomain.ResourceGroupItemInput {
	item.ItemType = resourcedomain.NormalizeGroupItemType(item.ItemType)
	item.WalletAssetType = resourcedomain.NormalizeWalletAssetType(item.WalletAssetType)
	if item.ItemType == resourcedomain.GroupItemTypeWalletAsset {
		item.ResourceID = 0
		item.Quantity = 1
		item.DurationMS = 0
		return item
	}
	item.ItemType = resourcedomain.GroupItemTypeResource
	item.WalletAssetType = ""
	item.WalletAssetAmount = 0
	return item
}

func validGroupWalletAssetType(assetType string) bool {
	switch resourcedomain.NormalizeWalletAssetType(assetType) {
	case ledger.AssetCoin:
		return true
	default:
		return false
	}
}

func applyResourcePricingToGiftCommand(command resourcedomain.GiftConfigCommand, resource resourcedomain.Resource) resourcedomain.GiftConfigCommand {
	switch resourcedomain.NormalizePriceType(resource.PriceType) {
	case resourcedomain.PriceTypeCoin:
		command.ChargeAssetType = ledger.AssetCoin
		command.CoinPrice = resource.CoinPrice
		command.GiftPointAmount = 0
	case resourcedomain.PriceTypeFree:
		command.ChargeAssetType = ledger.AssetCoin
		command.CoinPrice = 0
		command.GiftPointAmount = 0
	}
	// 后台已取消热度配置；旧列只保留兼容写入，数值始终跟真实 COIN 价格一致。
	command.HeatValue = command.CoinPrice
	return command
}

func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand {
	command.GiftID = strings.TrimSpace(command.GiftID)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	command.Name = strings.TrimSpace(command.Name)
	command.PresentationJSON = normalizeJSONObject(command.PresentationJSON)
	command.CPRelationType = normalizeCPRelationType(command.CPRelationType)
	command.PriceVersion = strings.TrimSpace(command.PriceVersion)
	command.RegionIDs = normalizeRegionIDs(command.RegionIDs)
	command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode)
	command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType))
	if command.ChargeAssetType == "" {
		command.ChargeAssetType = ledger.AssetCoin
	}
	command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes)
	// gift_point_amount 是历史字段，礼物配置保存时固定归零，送礼结算只读取真实 COIN 价格。
	command.GiftPointAmount = 0
	if command.EffectiveAtMS < 0 {
		command.EffectiveAtMS = 0
	}
	if command.EffectiveFromMS < 0 {
		command.EffectiveFromMS = 0
	}
	if command.EffectiveToMS < 0 {
		command.EffectiveToMS = 0
	}
	return command
}

func normalizeGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) resourcedomain.GiftTypeConfigCommand {
	command.TypeCode = resourcedomain.NormalizeGiftTypeCode(command.TypeCode)
	command.Name = strings.TrimSpace(command.Name)
	command.TabKey = resourcedomain.NormalizeGiftTypeTabKey(command.TabKey)
	command.Status = resourcedomain.NormalizeStatus(command.Status)
	return command
}

func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error {
	if command.GiftID == "" || command.ResourceID <= 0 || command.Name == "" || command.PriceVersion == "" {
		return xerr.New(xerr.InvalidArgument, "gift config command is incomplete")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	if command.CoinPrice < 0 || command.HeatValue < 0 {
		return xerr.New(xerr.InvalidArgument, "gift price is invalid")
	}
	if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) {
		return xerr.New(xerr.InvalidArgument, "gift type is invalid")
	}
	if command.CPRelationType != "" && command.GiftTypeCode != resourcedomain.GiftTypeCP {
		return xerr.New(xerr.InvalidArgument, "cp relation type requires cp gift type")
	}
	if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) {
		return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid")
	}
	if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS {
		return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid")
	}
	for _, effectType := range command.EffectTypes {
		if !resourcedomain.ValidGiftEffectType(effectType) {
			return xerr.New(xerr.InvalidArgument, "gift effect type is invalid")
		}
	}
	if len(command.RegionIDs) == 0 {
		return xerr.New(xerr.InvalidArgument, "gift regions are required")
	}
	for _, regionID := range command.RegionIDs {
		if regionID < 0 {
			return xerr.New(xerr.InvalidArgument, "gift region is invalid")
		}
	}
	return nil
}

func validateGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) error {
	if !resourcedomain.ValidGiftTypeCode(command.TypeCode) || command.Name == "" || !resourcedomain.ValidGiftTypeTabKey(command.TabKey) {
		return xerr.New(xerr.InvalidArgument, "gift type config is incomplete")
	}
	if !resourcedomain.ValidStatus(command.Status) {
		return xerr.New(xerr.InvalidArgument, "status is invalid")
	}
	return nil
}

func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand {
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
	if command.Quantity == 0 {
		command.Quantity = 1
	}
	return command
}

func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) error {
	if command.CommandID == "" || command.TargetUserID <= 0 || command.ResourceID <= 0 || command.Quantity <= 0 || command.OperatorUserID <= 0 || command.Reason == "" {
		return xerr.New(xerr.InvalidArgument, "resource grant command is incomplete")
	}
	if command.DurationMS < 0 {
		return xerr.New(xerr.InvalidArgument, "duration_ms is invalid")
	}
	return nil
}

func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand {
	command.CommandID = strings.TrimSpace(command.CommandID)
	command.Reason = strings.TrimSpace(command.Reason)
	command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource)
	return command
}

func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) error {
	if command.CommandID == "" || command.TargetUserID <= 0 || command.GroupID <= 0 || command.OperatorUserID <= 0 || command.Reason == "" {
		return xerr.New(xerr.InvalidArgument, "resource group grant command is incomplete")
	}
	if command.GrantSource == resourcedomain.GrantSourceManagerCenter {
		return xerr.New(xerr.InvalidArgument, "manager center cannot grant resource group")
	}
	return nil
}

func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if grantSource == resourcedomain.GrantSourceManagerCenter && !resource.ManagerGrantEnabled {
		return xerr.New(xerr.PermissionDenied, "resource is not enabled for manager center grant")
	}
	return nil
}

func validateGrantableResource(resource resourcedomain.Resource) error {
	if resource.Status != resourcedomain.StatusActive {
		return xerr.New(xerr.Conflict, "resource is disabled")
	}
	if !resource.Grantable {
		return xerr.New(xerr.Conflict, "resource is not grantable")
	}
	if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) {
		return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin")
	}
	return nil
}

func validateActiveResourceGroupItems(items []resourcedomain.ResourceGroupItem) error {
	if len(items) == 0 {
		return xerr.New(xerr.InvalidArgument, "resource group has no items")
	}
	for _, item := range items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource {
			continue
		}
		if err := validateActiveResourceGroupResource(item.Resource); err != nil {
			return err
		}
	}
	return nil
}

func validateActiveResourceGroupResource(resource resourcedomain.Resource) error {
	if err := validateGrantableResource(resource); err != nil {
		return err
	}
	if resourcedomain.IsCoinResource(resource) {
		return xerr.New(xerr.InvalidArgument, "coin resource must use wallet asset item")
	}
	return nil
}

func isEquipableResourceType(resourceType string) bool {
	switch resourcedomain.NormalizeResourceType(resourceType) {
	case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge, resourcedomain.TypeMicSeatAnimation:
		return true
	default:
		return false
	}
}

func resourceJSONPayload(scopes []string, metadata string) (string, string, error) {
	scopes = normalizeStringList(scopes)
	scopeJSON, err := json.Marshal(scopes)
	if err != nil {
		return "", "", err
	}
	return string(scopeJSON), normalizeJSONObject(metadata), nil
}

func normalizeStringList(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.ToLower(strings.TrimSpace(value))
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func normalizeRegionIDs(values []int64) []int64 {
	if len(values) == 0 {
		return []int64{0}
	}
	seen := make(map[int64]struct{}, len(values))
	out := make([]int64, 0, len(values))
	for _, value := range values {
		if value < 0 {
			out = append(out, value)
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	slices.Sort(out)
	return out
}

func normalizeGiftEffectTypes(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = resourcedomain.NormalizeGiftEffectType(value)
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	sort.Strings(out)
	return out
}

func parseStringArray(value string) []string {
	var out []string
	if err := json.Unmarshal([]byte(value), &out); err != nil {
		return nil
	}
	return normalizeStringList(out)
}

func normalizeJSONObject(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "{}"
	}
	var raw any
	if err := json.Unmarshal([]byte(value), &raw); err != nil {
		return "{}"
	}
	if _, ok := raw.(map[string]any); !ok {
		return "{}"
	}
	return value
}

func cpRelationTypeFromPresentationJSON(value string) string {
	var payload map[string]any
	if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil {
		return ""
	}
	if raw, ok := payload["cp_relation_type"].(string); ok {
		return normalizeCPRelationType(raw)
	}
	return ""
}

func giftPresentationWithCPRelationType(value string, relationType string) string {
	relationType = normalizeCPRelationType(relationType)
	var payload map[string]any
	if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil || payload == nil {
		payload = map[string]any{}
	}
	// 空值表示清除 CP 关系类型；非 CP 礼物保存时不能残留旧的 cp_relation_type。
	if relationType == "" {
		delete(payload, "cp_relation_type")
	} else {
		payload["cp_relation_type"] = relationType
	}
	encoded, err := json.Marshal(payload)
	if err != nil {
		return "{}"
	}
	return string(encoded)
}

func normalizeCPRelationType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "cp":
		return "cp"
	case "brother":
		return "brother"
	case "sister":
		return "sister"
	default:
		return ""
	}
}

func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent {
	eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID)
	return walletOutboxEvent{
		EventID:       "wev_" + stableHash(eventKey),
		EventType:     eventType,
		TransactionID: "",
		CommandID:     commandID,
		UserID:        userID,
		AssetType:     resourceOutboxAsset,
		Payload:       payload,
		CreatedAtMS:   nowMs,
	}
}

func (r *Repository) insertResourceOutboxNoTx(ctx context.Context, eventType string, commandID string, userID int64, resourceID int64, payload any) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	nowMs := time.Now().UnixMilli()
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{resourceOutboxEvent(eventType, commandID, userID, resourceID, payload, nowMs)}); err != nil {
		return err
	}
	return tx.Commit()
}

func grantResourceRequestHash(command resourcedomain.GrantResourceCommand) string {
	return stableHash(fmt.Sprintf("resource_grant|%s|%d|%d|%d|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode), command.TargetUserID, command.ResourceID, command.Quantity, command.DurationMS,
		command.Reason, command.OperatorUserID, command.GrantSource,
	))
}

func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupCommand) string {
	return stableHash(fmt.Sprintf("resource_group_grant|%s|%d|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode), command.TargetUserID, command.GroupID,
		command.Reason, command.OperatorUserID, command.GrantSource,
	))
}

func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string {
	return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d",
		appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID,
	))
}

func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string {
	return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity))
}

func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType string, amount int64) string {
	return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
}

func resourceGrantID(appCode string, commandID string) string {
	return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}

func entitlementID(appCode string, grantID string, resourceID int64, nowMs int64) string {
	return "ent_" + stableHash(fmt.Sprintf("%s|%s|%d|%d", appcode.Normalize(appCode), grantID, resourceID, nowMs))
}
