2026-06-04 22:57:31 +08:00

3597 lines
135 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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)
giftPointAmount := int64(0)
if priceType == resourcedomain.PriceTypeCoin {
coinPrice = resource.CoinPrice
giftPointAmount = resource.GiftPointAmount
}
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 = price.GiftPointAmount
item.HeatValue = price.HeatValue
}
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
}
// 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 := 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
entitlement_id = VALUES(entitlement_id),
resource_id = VALUES(resource_id),
updated_at_ms = VALUES(updated_at_ms)`,
command.AppCode, command.UserID, entitlement.Resource.ResourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs,
); err != nil {
return resourcedomain.UserResourceEntitlement{}, err
}
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
}
entitlement.Equipped = true
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")
}
// 取消佩戴要和 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 标记:
// 这样每个 user_id + 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()
presentation := normalizeJSONObject(command.PresentationJSON)
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
}
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) 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 {
_, 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, command.HeatValue, 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.GiftPointAmount = price.GiftPointAmount
gift.HeatValue = price.HeatValue
}
gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, 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)
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.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)
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 (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 {
command.GiftPointAmount = command.CoinPrice
}
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 || command.GiftPointAmount < 0 || command.GiftPointAmount != command.CoinPrice {
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 = resource.GiftPointAmount
case resourcedomain.PriceTypeFree:
command.ChargeAssetType = ledger.AssetCoin
command.CoinPrice = 0
command.GiftPointAmount = 0
}
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.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)
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.GiftPointAmount < 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 !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:
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 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))
}