2026-05-12 09:53:20 +08:00

2427 lines
90 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"
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)
}
// 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, 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,
usageJSON,
command.AssetURL,
command.PreviewURL,
command.AnimationURL,
metadataJSON,
command.SortOrder,
command.OperatorUserID,
command.OperatorUserID,
nowMs,
nowMs,
)
if err != nil {
return resourcedomain.Resource{}, 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 = ?,
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,
usageJSON,
command.AssetURL,
command.PreviewURL,
command.AnimationURL,
metadataJSON,
command.SortOrder,
command.OperatorUserID,
nowMs,
command.AppCode,
command.ResourceID,
)
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.ResourceID)
if err != nil {
return resourcedomain.Resource{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs),
}); err != nil {
return resourcedomain.Resource{}, err
}
if err := tx.Commit(); err != nil {
return resourcedomain.Resource{}, err
}
return resource, 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
}
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
}
// 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")
}
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")
}
where += ` AND r.resource_type = ?`
args = append(args, resourceType)
}
if query.ActiveOnly {
nowMs := time.Now().UnixMilli()
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
}
return entitlement, 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()
}
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 err := validateGiftConfigCommand(command); err != nil {
return resourcedomain.GiftConfig{}, err
}
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")
}
if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive {
return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled")
}
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, 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, 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) 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,
`+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) 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) 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,
&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 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,
&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,
&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 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,
&resource.AppCode,
&resource.ResourceID,
&resource.ResourceCode,
&resource.ResourceType,
&resource.Name,
&resource.Status,
&resource.Grantable,
&resource.ManagerGrantEnabled,
&resource.GrantStrategy,
&resource.WalletAssetType,
&resource.WalletAssetAmount,
&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, 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(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 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,
` + resourceColumnsWithAlias("r") + `
FROM user_resource_entitlements e
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_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 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 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 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 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.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")
}
return nil
}
if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 {
return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit")
}
return nil
}
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, ledger.AssetDiamond:
return true
default:
return false
}
}
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 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 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.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 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))
}