2427 lines
90 KiB
Go
2427 lines
90 KiB
Go
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(®ionID); 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))
|
||
}
|