package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "math" "slices" "sort" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) const ( bizTypeResourceGrant = "resource_grant" bizTypeResourceShopPurchase = "resource_shop_purchase" resourceOutboxAsset = "RESOURCE" ) type scanTarget interface { Scan(dest ...any) error } type sqlRowQuerier interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } type sqlQuerier interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) } type sqlExecer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } // ListResources 返回后台资源库列表;App 入口会设置 active_only 只读上架资源。 func (r *Repository) ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query.AppCode = appcode.FromContext(ctx) query = normalizeResourceListQuery(query) where, args := resourceWhereSQL(query) total, err := r.countResourceRows(ctx, "resources", where, args...) if err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+` `+where+` ORDER BY sort_order ASC, resource_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() items := make([]resourcedomain.Resource, 0, query.PageSize) for rows.Next() { item, err := scanResource(rows) if err != nil { return nil, 0, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } return items, total, nil } // GetResource 读取单个资源详情。 func (r *Repository) GetResource(ctx context.Context, resourceID int64) (resourcedomain.Resource, error) { if r == nil || r.db == nil { return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ?`, appcode.FromContext(ctx), resourceID) resource, err := scanResource(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") } return resource, err } // CreateResource 写入一个资源配置。所有资源事实只保存在 wallet-service 库。 func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { if r == nil || r.db == nil { return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeResourceCommand(command) if err := validateResourceCommand(command); err != nil { return resourcedomain.Resource{}, err } nowMs := time.Now().UnixMilli() usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) if err != nil { return resourcedomain.Resource{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.Resource{}, err } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` INSERT INTO resources ( app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, command.ResourceCode, command.ResourceType, command.Name, command.Status, command.Grantable, command.ManagerGrantEnabled, command.GrantStrategy, command.WalletAssetType, command.WalletAssetAmount, command.PriceType, command.CoinPrice, command.GiftPointAmount, usageJSON, command.AssetURL, command.PreviewURL, command.AnimationURL, metadataJSON, command.SortOrder, command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, ) if err != nil { return resourcedomain.Resource{}, mapResourceWriteError(err) } resourceID, err := result.LastInsertId() if err != nil { return resourcedomain.Resource{}, err } resource, err := r.getResourceForUpdate(ctx, tx, resourceID) if err != nil { return resourcedomain.Resource{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:create:%d", resourceID, nowMs), 0, resourceID, resource, nowMs), }); err != nil { return resourcedomain.Resource{}, err } if err := tx.Commit(); err != nil { return resourcedomain.Resource{}, err } return resource, nil } // UpdateResource 全量更新资源配置;当前开发阶段不做兼容性合并。 func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.ResourceCommand) (resourcedomain.Resource, error) { if r == nil || r.db == nil { return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if command.ResourceID <= 0 { return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeResourceCommand(command) if err := validateResourceCommand(command); err != nil { return resourcedomain.Resource{}, err } nowMs := time.Now().UnixMilli() usageJSON, metadataJSON, err := resourceJSONPayload(command.UsageScopes, command.MetadataJSON) if err != nil { return resourcedomain.Resource{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.Resource{}, err } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, ` UPDATE resources SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?, grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?, price_type = ?, coin_price = ?, gift_point_amount = ?, usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?, metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`, command.ResourceCode, command.ResourceType, command.Name, command.Status, command.Grantable, command.ManagerGrantEnabled, command.GrantStrategy, command.WalletAssetType, command.WalletAssetAmount, command.PriceType, command.CoinPrice, command.GiftPointAmount, usageJSON, command.AssetURL, command.PreviewURL, command.AnimationURL, metadataJSON, command.SortOrder, command.OperatorUserID, nowMs, command.AppCode, command.ResourceID, ) if err != nil { return resourcedomain.Resource{}, mapResourceWriteError(err) } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.Resource{}, err } else if affected == 0 { return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") } resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) if err != nil { return resourcedomain.Resource{}, err } events := []walletOutboxEvent{ resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:update:%d", command.ResourceID, nowMs), 0, command.ResourceID, resource, nowMs), } giftEvents, err := r.syncGiftPricesForResourceTx(ctx, tx, resource, nowMs) if err != nil { return resourcedomain.Resource{}, err } events = append(events, giftEvents...) if err := r.insertWalletOutbox(ctx, tx, events); err != nil { return resourcedomain.Resource{}, err } if err := tx.Commit(); err != nil { return resourcedomain.Resource{}, err } return resource, nil } func (r *Repository) syncGiftPricesForResourceTx(ctx context.Context, tx *sql.Tx, resource resourcedomain.Resource, nowMs int64) ([]walletOutboxEvent, error) { // 礼物资源的价格是后台编辑入口,实际送礼扣费读取 wallet_gift_prices。 // 因此资源价格变更后,必须在同一个事务内把已绑定礼物的 active 价格同步过去, // 同时发 GiftConfigChanged outbox,让依赖礼物配置快照的下游可以刷新缓存。 if resource.ResourceType != resourcedomain.TypeGift { return nil, nil } priceType := resourcedomain.NormalizePriceType(resource.PriceType) if priceType != resourcedomain.PriceTypeCoin && priceType != resourcedomain.PriceTypeFree { return nil, nil } rows, err := tx.QueryContext(ctx, ` SELECT gift_id FROM gift_configs WHERE app_code = ? AND resource_id = ? ORDER BY gift_id`, appcode.FromContext(ctx), resource.ResourceID, ) if err != nil { return nil, err } defer rows.Close() giftIDs := make([]string, 0) for rows.Next() { var giftID string if err := rows.Scan(&giftID); err != nil { return nil, err } giftIDs = append(giftIDs, giftID) } if err := rows.Err(); err != nil { return nil, err } if len(giftIDs) == 0 { return nil, nil } coinPrice := int64(0) if priceType == resourcedomain.PriceTypeCoin { coinPrice = resource.CoinPrice } // gift_point_amount 是历史字段,资源改价同步礼物价格时固定写 0,避免旧积分口径重新进入送礼链路。 giftPointAmount := int64(0) if _, err := tx.ExecContext(ctx, ` UPDATE wallet_gift_prices gp JOIN gift_configs gc ON gc.app_code = gp.app_code AND gc.gift_id = gp.gift_id SET gp.charge_asset_type = ?, gp.coin_price = ?, gp.gift_point_amount = ?, gp.updated_at_ms = ? WHERE gc.app_code = ? AND gc.resource_id = ? AND gp.status = 'active'`, ledger.AssetCoin, coinPrice, giftPointAmount, nowMs, appcode.FromContext(ctx), resource.ResourceID, ); err != nil { return nil, err } events := make([]walletOutboxEvent, 0, len(giftIDs)) for _, giftID := range giftIDs { events = append(events, resourceOutboxEvent( "GiftConfigChanged", fmt.Sprintf("gift:%s:resource_price:%d", giftID, nowMs), 0, resource.ResourceID, map[string]any{ "gift_id": giftID, "resource_id": resource.ResourceID, "coin_price": coinPrice, "gift_point_amount": giftPointAmount, }, nowMs, )) } return events, nil } func (r *Repository) SetResourceStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.Resource, error) { if r == nil || r.db == nil { return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if command.ID <= 0 { return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "resource_id is required") } ctx = contextWithCommandApp(ctx, command.AppCode) command.Status = resourcedomain.NormalizeStatus(command.Status) if !resourcedomain.ValidStatus(command.Status) { return resourcedomain.Resource{}, xerr.New(xerr.InvalidArgument, "status is invalid") } nowMs := time.Now().UnixMilli() tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.Resource{}, err } defer func() { _ = tx.Rollback() }() result, err := tx.ExecContext(ctx, `UPDATE resources SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`, command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, ) if err != nil { return resourcedomain.Resource{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.Resource{}, err } else if affected == 0 { return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") } resource, err := r.getResourceForUpdate(ctx, tx, command.ID) if err != nil { return resourcedomain.Resource{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceChanged", fmt.Sprintf("resource:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, resource, nowMs), }); err != nil { return resourcedomain.Resource{}, err } if err := tx.Commit(); err != nil { return resourcedomain.Resource{}, err } return resource, nil } func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomain.ListResourceGroupsQuery) ([]resourcedomain.ResourceGroup, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query.AppCode = appcode.FromContext(ctx) query = normalizeResourceGroupListQuery(query) where, args := resourceGroupWhereSQL(query) total, err := r.countResourceRows(ctx, "resource_groups", where, args...) if err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, group_id, group_code, name, status, description, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms FROM resource_groups `+where+` ORDER BY sort_order ASC, group_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() groups := make([]resourcedomain.ResourceGroup, 0, query.PageSize) for rows.Next() { group, err := scanResourceGroup(rows) if err != nil { return nil, 0, err } group.Items, err = r.listResourceGroupItems(ctx, group.GroupID) if err != nil { return nil, 0, err } groups = append(groups, group) } if err := rows.Err(); err != nil { return nil, 0, err } return groups, total, nil } func (r *Repository) GetResourceGroup(ctx context.Context, groupID int64) (resourcedomain.ResourceGroup, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } group, err := r.getResourceGroup(ctx, r.db, groupID, false) if err != nil { return resourcedomain.ResourceGroup{}, err } group.Items, err = r.listResourceGroupItems(ctx, groupID) return group, err } func (r *Repository) CreateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeResourceGroupCommand(command) if err := validateResourceGroupCommand(command, true); err != nil { return resourcedomain.ResourceGroup{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceGroup{}, err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() result, err := tx.ExecContext(ctx, ` INSERT INTO resource_groups ( app_code, group_code, name, status, description, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, ) if err != nil { return resourcedomain.ResourceGroup{}, err } groupID, err := result.LastInsertId() if err != nil { return resourcedomain.ResourceGroup{}, err } if err := r.replaceResourceGroupItemsTx(ctx, tx, groupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { return resourcedomain.ResourceGroup{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:create:%d", groupID, nowMs), 0, groupID, command, nowMs), }); err != nil { return resourcedomain.ResourceGroup{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceGroup{}, err } return r.GetResourceGroup(ctx, groupID) } func (r *Repository) UpdateResourceGroup(ctx context.Context, command resourcedomain.ResourceGroupCommand) (resourcedomain.ResourceGroup, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if command.GroupID <= 0 { return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeResourceGroupCommand(command) if err := validateResourceGroupCommand(command, true); err != nil { return resourcedomain.ResourceGroup{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceGroup{}, err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() result, err := tx.ExecContext(ctx, ` UPDATE resource_groups SET group_code = ?, name = ?, status = ?, description = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`, command.GroupCode, command.Name, command.Status, command.Description, command.SortOrder, command.OperatorUserID, nowMs, command.AppCode, command.GroupID, ) if err != nil { return resourcedomain.ResourceGroup{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.ResourceGroup{}, err } else if affected == 0 { return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") } if err := r.replaceResourceGroupItemsTx(ctx, tx, command.GroupID, command.Items, command.Status == resourcedomain.StatusActive, nowMs); err != nil { return resourcedomain.ResourceGroup{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:update:%d", command.GroupID, nowMs), 0, command.GroupID, command, nowMs), }); err != nil { return resourcedomain.ResourceGroup{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceGroup{}, err } return r.GetResourceGroup(ctx, command.GroupID) } func (r *Repository) SetResourceGroupStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.ResourceGroup, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if command.ID <= 0 { return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "group_id is required") } ctx = contextWithCommandApp(ctx, command.AppCode) command.Status = resourcedomain.NormalizeStatus(command.Status) if !resourcedomain.ValidStatus(command.Status) { return resourcedomain.ResourceGroup{}, xerr.New(xerr.InvalidArgument, "status is invalid") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceGroup{}, err } defer func() { _ = tx.Rollback() }() if _, err := r.getResourceGroup(ctx, tx, command.ID, true); err != nil { return resourcedomain.ResourceGroup{}, err } if command.Status == resourcedomain.StatusActive { items, err := r.listResourceGroupItemsTx(ctx, tx, command.ID, true) if err != nil { return resourcedomain.ResourceGroup{}, err } if err := validateActiveResourceGroupItems(items); err != nil { return resourcedomain.ResourceGroup{}, err } } nowMs := time.Now().UnixMilli() result, err := tx.ExecContext(ctx, `UPDATE resource_groups SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND group_id = ?`, command.Status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), command.ID, ) if err != nil { return resourcedomain.ResourceGroup{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.ResourceGroup{}, err } else if affected == 0 { return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") } group, err := r.getResourceGroup(ctx, tx, command.ID, false) if err != nil { return resourcedomain.ResourceGroup{}, err } group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.ID, false) if err != nil { return resourcedomain.ResourceGroup{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceGroupChanged", fmt.Sprintf("resource_group:%d:status:%s:%d", command.ID, command.Status, nowMs), 0, command.ID, group, nowMs), }); err != nil { return resourcedomain.ResourceGroup{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceGroup{}, err } return group, nil } func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.ListGiftConfigsQuery) ([]resourcedomain.GiftConfig, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query.AppCode = appcode.FromContext(ctx) query = normalizeGiftConfigListQuery(query) where, args := giftConfigWhereSQL(query) var total int64 if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM gift_configs gc JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id `+where, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+` ORDER BY gc.sort_order ASC, gc.created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() nowMs := time.Now().UnixMilli() items := make([]resourcedomain.GiftConfig, 0, query.PageSize) for rows.Next() { item, err := scanGiftConfig(rows) if err != nil { return nil, 0, err } item.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, item.GiftID) if err != nil { return nil, 0, err } price, priceErr := r.latestGiftPrice(ctx, r.db, item.GiftID, nowMs) if priceErr == nil { item.PriceVersion = price.PriceVersion item.ChargeAssetType = price.ChargeAssetType item.CoinPrice = price.CoinPrice item.GiftPointAmount = 0 item.HeatValue = price.CoinPrice } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } return items, total, nil } // ListGiftTypeConfigs 返回礼物面板 tab 类型配置;默认类型会按 app_code 自动补齐,避免新租户缺配置。 func (r *Repository) ListGiftTypeConfigs(ctx context.Context, query resourcedomain.ListGiftTypeConfigsQuery) ([]resourcedomain.GiftTypeConfig, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) if err := r.ensureDefaultGiftTypeConfigs(ctx, r.db); err != nil { return nil, err } query.AppCode = appcode.FromContext(ctx) query = normalizeGiftTypeConfigListQuery(query) where, args := giftTypeConfigWhereSQL(query) rows, err := r.db.QueryContext(ctx, giftTypeConfigSelectSQL()+where+` ORDER BY sort_order ASC, type_code ASC`, args...) if err != nil { return nil, err } defer rows.Close() items := make([]resourcedomain.GiftTypeConfig, 0, len(resourcedomain.DefaultGiftTypeConfigs(query.AppCode))) for rows.Next() { item, err := scanGiftTypeConfig(rows) if err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, err } return items, nil } func (r *Repository) CreateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { return r.upsertGiftConfig(ctx, command, false) } func (r *Repository) UpdateGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand) (resourcedomain.GiftConfig, error) { return r.upsertGiftConfig(ctx, command, true) } func (r *Repository) SetGiftConfigStatus(ctx context.Context, command resourcedomain.StatusCommand) (resourcedomain.GiftConfig, error) { if r == nil || r.db == nil { return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if strings.TrimSpace(command.StringID) == "" { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift_id is required") } ctx = contextWithCommandApp(ctx, command.AppCode) status := resourcedomain.NormalizeStatus(command.Status) if !resourcedomain.ValidStatus(status) { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "status is invalid") } nowMs := time.Now().UnixMilli() result, err := r.db.ExecContext(ctx, `UPDATE gift_configs SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`, status, command.OperatorUserID, nowMs, appcode.FromContext(ctx), strings.TrimSpace(command.StringID), ) if err != nil { return resourcedomain.GiftConfig{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.GiftConfig{}, err } else if affected == 0 { return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") } gift, err := r.getGiftConfig(ctx, strings.TrimSpace(command.StringID)) if err != nil { return resourcedomain.GiftConfig{}, err } _ = r.insertResourceOutboxNoTx(ctx, "GiftConfigChanged", fmt.Sprintf("gift:%s:status:%s:%d", gift.GiftID, status, nowMs), 0, gift.ResourceID, gift) return gift, nil } // UpsertGiftTypeConfig 更新礼物类型 tab 展示配置;type_code 是礼物表引用的稳定业务键。 func (r *Repository) UpsertGiftTypeConfig(ctx context.Context, command resourcedomain.GiftTypeConfigCommand) (resourcedomain.GiftTypeConfig, error) { if r == nil || r.db == nil { return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeGiftTypeConfigCommand(command) if err := validateGiftTypeConfigCommand(command); err != nil { return resourcedomain.GiftTypeConfig{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.GiftTypeConfig{}, err } defer func() { _ = tx.Rollback() }() if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { return resourcedomain.GiftTypeConfig{}, err } nowMs := time.Now().UnixMilli() if _, err := tx.ExecContext(ctx, ` INSERT INTO gift_type_configs ( app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), tab_key = VALUES(tab_key), status = VALUES(status), sort_order = VALUES(sort_order), updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms)`, command.AppCode, command.TypeCode, command.Name, command.TabKey, command.Status, command.SortOrder, command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, ); err != nil { return resourcedomain.GiftTypeConfig{}, err } item, err := r.getGiftTypeConfigTx(ctx, tx, command.TypeCode) if err != nil { return resourcedomain.GiftTypeConfig{}, err } if err := tx.Commit(); err != nil { return resourcedomain.GiftTypeConfig{}, err } return item, nil } // GrantResource 发放单个资源;金币资源和非金币权益在同一个事务里处理。 func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeGrantResourceCommand(command) if err := validateGrantResourceCommand(command); err != nil { return resourcedomain.ResourceGrant{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceGrant{}, err } defer func() { _ = tx.Rollback() }() requestHash := grantResourceRequestHash(command) if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { if err != nil { return resourcedomain.ResourceGrant{}, err } return r.getResourceGrantTx(ctx, tx, grantID) } nowMs := time.Now().UnixMilli() resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) if err != nil { return resourcedomain.ResourceGrant{}, err } if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil { return resourcedomain.ResourceGrant{}, err } grantID := resourceGrantID(command.AppCode, command.CommandID) if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil { return resourcedomain.ResourceGrant{}, err } if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil { return resourcedomain.ResourceGrant{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs), }); err != nil { return resourcedomain.ResourceGrant{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceGrant{}, err } return r.GetResourceGrant(ctx, grantID) } // GrantResourceGroup 锁定资源组快照并原子发放组内所有资源。 func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeGrantResourceGroupCommand(command) if err := validateGrantResourceGroupCommand(command); err != nil { return resourcedomain.ResourceGrant{}, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceGrant{}, err } defer func() { _ = tx.Rollback() }() requestHash := grantResourceGroupRequestHash(command) if grantID, exists, err := r.lookupResourceGrant(ctx, tx, command.CommandID, requestHash); err != nil || exists { if err != nil { return resourcedomain.ResourceGrant{}, err } return r.getResourceGrantTx(ctx, tx, grantID) } nowMs := time.Now().UnixMilli() group, err := r.getResourceGroup(ctx, tx, command.GroupID, true) if err != nil { return resourcedomain.ResourceGrant{}, err } if group.Status != resourcedomain.StatusActive { return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource group is disabled") } group.Items, err = r.listResourceGroupItemsTx(ctx, tx, command.GroupID, true) if err != nil { return resourcedomain.ResourceGrant{}, err } if len(group.Items) == 0 { return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group has no items") } groupSnapshot, err := json.Marshal(group) if err != nil { return resourcedomain.ResourceGrant{}, err } grantID := resourceGrantID(command.AppCode, command.CommandID) if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil { return resourcedomain.ResourceGrant{}, err } for _, item := range group.Items { if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset { if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil { return resourcedomain.ResourceGrant{}, err } continue } if err := validateActiveResourceGroupResource(item.Resource); err != nil { return resourcedomain.ResourceGrant{}, err } if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil { return resourcedomain.ResourceGrant{}, err } } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs), }); err != nil { return resourcedomain.ResourceGrant{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceGrant{}, err } return r.GetResourceGrant(ctx, grantID) } func (r *Repository) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) if query.UserID <= 0 { return nil, xerr.New(xerr.InvalidArgument, "user_id is required") } nowMs := time.Now().UnixMilli() // equipment 表只记录“当前佩戴指针”,真正的有效期仍以 entitlement 为准。 // 所以背包列表读取前先清理当前用户已失效的佩戴指针,避免过期道具继续显示 equipped=true。 pruneResourceTypes := []string{} where := `WHERE e.app_code = ? AND e.user_id = ?` args := []any{appcode.FromContext(ctx), query.UserID} if strings.TrimSpace(query.ResourceType) != "" { resourceType := resourcedomain.NormalizeResourceType(query.ResourceType) if !resourcedomain.ValidResourceType(resourceType) { return nil, xerr.New(xerr.InvalidArgument, "resource_type is invalid") } // 只有可佩戴资源才可能在 equipment 表中留下指针;礼物/金币等非佩戴资源不参与清理。 if isEquipableResourceType(resourceType) { pruneResourceTypes = []string{resourceType} } where += ` AND r.resource_type = ?` args = append(args, resourceType) } if err := r.pruneExpiredUserResourceEquipment(ctx, []int64{query.UserID}, pruneResourceTypes, nowMs); err != nil { return nil, err } if query.ActiveOnly { // App 背包只需要当前可用权益;后台或排障读取可关闭 ActiveOnly 看历史/过期记录。 where += ` AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0` args = append(args, nowMs, nowMs) } rows, err := r.db.QueryContext(ctx, userResourceSelectSQL()+where+` ORDER BY e.updated_at_ms DESC, e.created_at_ms DESC`, args...) if err != nil { return nil, err } defer rows.Close() items := make([]resourcedomain.UserResourceEntitlement, 0) for rows.Next() { item, err := scanUserResourceEntitlement(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (r *Repository) EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error) { if r == nil || r.db == nil { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command.EntitlementID = strings.TrimSpace(command.EntitlementID) if command.UserID <= 0 || command.ResourceID <= 0 { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "user_id and resource_id are required") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.UserResourceEntitlement{}, err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() entitlement, err := r.queryEquipableEntitlementForUpdate(ctx, tx, command, nowMs) if err != nil { return resourcedomain.UserResourceEntitlement{}, err } if !isEquipableResourceType(entitlement.Resource.ResourceType) { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") } if _, err := tx.ExecContext(ctx, ` INSERT INTO user_resource_equipment ( app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE entitlement_id = VALUES(entitlement_id), resource_id = VALUES(resource_id), updated_at_ms = VALUES(updated_at_ms)`, command.AppCode, command.UserID, entitlement.Resource.ResourceType, entitlement.EntitlementID, entitlement.ResourceID, nowMs, ); err != nil { return resourcedomain.UserResourceEntitlement{}, err } eventCommandID := fmt.Sprintf("equip:%s:%d", entitlement.EntitlementID, time.Now().UnixNano()) if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, command.ResourceID, map[string]any{ "action": "equip", "app_code": command.AppCode, "user_id": command.UserID, "resource_id": command.ResourceID, "resource_type": entitlement.Resource.ResourceType, "entitlement": entitlement, "updated_at_ms": nowMs, "event_command": eventCommandID, }, nowMs), }); err != nil { return resourcedomain.UserResourceEntitlement{}, err } if err := tx.Commit(); err != nil { return resourcedomain.UserResourceEntitlement{}, err } entitlement.Equipped = true return entitlement, nil } func (r *Repository) UnequipUserResource(ctx context.Context, command resourcedomain.UnequipUserResourceCommand) (resourcedomain.UnequipUserResourceResult, error) { if r == nil || r.db == nil { return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) if command.UserID <= 0 || command.ResourceType == "" { return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "user_id and resource_type are required") } if !resourcedomain.ValidResourceType(command.ResourceType) || !isEquipableResourceType(command.ResourceType) { return resourcedomain.UnequipUserResourceResult{}, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") } // 取消佩戴要和 outbox 事件在同一事务内提交: // UI 投影可以监听 UserResourceChanged,但不能在 equipment 删除失败时收到“已卸下”的假事件。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.UnequipUserResourceResult{}, err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() result, err := tx.ExecContext(ctx, ` DELETE FROM user_resource_equipment WHERE app_code = ? AND user_id = ? AND resource_type = ?`, command.AppCode, command.UserID, command.ResourceType, ) if err != nil { return resourcedomain.UnequipUserResourceResult{}, err } // RowsAffected=0 表示用户本来就没佩戴该类型;这个场景按幂等取消处理,不抛业务错误。 affected, err := result.RowsAffected() if err != nil { return resourcedomain.UnequipUserResourceResult{}, err } if affected > 0 { // resource_id 对“取消某一类佩戴”没有单一值,payload 用 resource_type 表示变更维度。 eventCommandID := fmt.Sprintf("unequip:%d:%s:%d", command.UserID, command.ResourceType, time.Now().UnixNano()) if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("UserResourceChanged", eventCommandID, command.UserID, 0, map[string]any{ "action": "unequip", "app_code": command.AppCode, "user_id": command.UserID, "resource_type": command.ResourceType, "updated_at_ms": nowMs, "event_command": eventCommandID, }, nowMs), }); err != nil { return resourcedomain.UnequipUserResourceResult{}, err } } if err := tx.Commit(); err != nil { return resourcedomain.UnequipUserResourceResult{}, err } return resourcedomain.UnequipUserResourceResult{ ResourceType: command.ResourceType, Unequipped: affected > 0, UpdatedAtMS: nowMs, }, nil } func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query resourcedomain.BatchGetUserEquippedResourcesQuery) ([]resourcedomain.UserEquippedResources, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) userIDs := compactPositiveInt64s(query.UserIDs) if len(userIDs) == 0 { return []resourcedomain.UserEquippedResources{}, nil } // 批量查询只允许可佩戴类型,避免把背包里不可展示为装扮的资源混进房间资料。 resourceTypes, err := normalizeEquipableResourceTypes(query.ResourceTypes) if err != nil { return nil, err } nowMs := time.Now().UnixMilli() // 先清理再查,保证调用方不需要理解“equipment 只是指针”的存储细节。 if err := r.pruneExpiredUserResourceEquipment(ctx, userIDs, resourceTypes, nowMs); err != nil { return nil, err } args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) args = append(args, nowMs, nowMs) typeFilter := "" if len(resourceTypes) > 0 { // resourceTypes 为空表示查询所有可佩戴类型;非空时只返回调用方关心的展示维度。 typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` args = append(args, stringAnyArgs(resourceTypes)...) } // 从 equipment 反查 entitlement/resource,而不是从 entitlement 扫描 equipped 标记: // 这样每个 user_id + resource_type 只会返回当前佩戴的那一个有效权益。 rows, err := r.db.QueryContext(ctx, ` SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id, e.created_at_ms, e.updated_at_ms, TRUE AS equipped, `+resourceColumnsWithAlias("r")+` FROM user_resource_equipment eq JOIN user_resource_entitlements e ON e.app_code = eq.app_code AND e.user_id = eq.user_id AND e.resource_id = eq.resource_id AND e.entitlement_id = eq.entitlement_id JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0 AND r.status = 'active'`+typeFilter+` ORDER BY eq.user_id ASC, eq.updated_at_ms DESC`, args..., ) if err != nil { return nil, err } defer rows.Close() grouped := make(map[int64][]resourcedomain.UserResourceEntitlement, len(userIDs)) for rows.Next() { item, err := scanUserResourceEntitlement(rows) if err != nil { return nil, err } item.Equipped = true grouped[item.UserID] = append(grouped[item.UserID], item) } if err := rows.Err(); err != nil { return nil, err } // 返回顺序按请求 user_ids 保持稳定,调用方可以直接和入参用户列表对齐。 result := make([]resourcedomain.UserEquippedResources, 0, len(userIDs)) for _, userID := range userIDs { result = append(result, resourcedomain.UserEquippedResources{ UserID: userID, Resources: grouped[userID], }) } return result, nil } func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query.AppCode = appcode.FromContext(ctx) query = normalizeResourceGrantsQuery(query) where := `WHERE app_code = ?` args := []any{query.AppCode} if query.TargetUserID > 0 { where += ` AND target_user_id = ?` args = append(args, query.TargetUserID) } if strings.TrimSpace(query.Status) != "" { where += ` AND status = ?` args = append(args, strings.ToLower(strings.TrimSpace(query.Status))) } total, err := r.countResourceRows(ctx, "resource_grants", where, args...) if err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, resourceGrantSelectSQL()+where+` ORDER BY created_at_ms DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]resourcedomain.ResourceGrant, 0, query.PageSize) for rows.Next() { grant, err := scanResourceGrant(rows) if err != nil { return nil, 0, err } grant.Items, err = r.listResourceGrantItems(ctx, grant.GrantID) if err != nil { return nil, 0, err } items = append(items, grant) } return items, total, rows.Err() } // ListResourceShopItems 返回 App 道具商店和后台配置共用的售卖视图;价格始终由资源一日价乘以售卖天数派生。 func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query.AppCode = appcode.FromContext(ctx) query = normalizeResourceShopItemsQuery(query) where, args := resourceShopItemsWhereSQL(query) var total int64 if err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM resource_shop_items si JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id `+where, args...).Scan(&total); err != nil { return nil, 0, err } rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+` ORDER BY si.sort_order ASC, si.shop_item_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() items := make([]resourcedomain.ResourceShopItem, 0, query.PageSize) for rows.Next() { item, err := scanResourceShopItem(rows) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, rows.Err() } // UpsertResourceShopItems 批量添加或重配售卖资源;同一个资源在商店只保留一个当前售卖配置。 func (r *Repository) UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeResourceShopItemsCommand(command) if err := validateResourceShopItemsCommand(command); err != nil { return nil, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() resourceIDs := make([]int64, 0, len(command.Items)) seen := make(map[int64]struct{}, len(command.Items)) for _, item := range command.Items { if _, exists := seen[item.ResourceID]; exists { return nil, xerr.New(xerr.InvalidArgument, "resource shop contains duplicate resource") } seen[item.ResourceID] = struct{}{} resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) if err != nil { return nil, err } if err := validateResourceShopResource(resource); err != nil { return nil, err } if _, err := tx.ExecContext(ctx, ` INSERT INTO resource_shop_items ( app_code, resource_id, status, duration_days, effective_from_ms, effective_to_ms, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), duration_days = VALUES(duration_days), effective_from_ms = VALUES(effective_from_ms), effective_to_ms = VALUES(effective_to_ms), sort_order = VALUES(sort_order), updated_by_user_id = VALUES(updated_by_user_id), updated_at_ms = VALUES(updated_at_ms)`, command.AppCode, item.ResourceID, item.Status, item.DurationDays, item.EffectiveFromMS, item.EffectiveToMS, item.SortOrder, command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, ); err != nil { return nil, err } resourceIDs = append(resourceIDs, item.ResourceID) } if err := tx.Commit(); err != nil { return nil, err } return r.listResourceShopItemsByResourceIDs(ctx, resourceIDs) } func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) { if r == nil || r.db == nil { return resourcedomain.ResourceShopItem{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command.Status = resourcedomain.NormalizeStatus(command.Status) if command.ShopItemID <= 0 { return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "shop_item_id is required") } if !resourcedomain.ValidStatus(command.Status) { return resourcedomain.ResourceShopItem{}, xerr.New(xerr.InvalidArgument, "status is invalid") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceShopItem{}, err } defer func() { _ = tx.Rollback() }() item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) if err != nil { return resourcedomain.ResourceShopItem{}, err } if command.Status == resourcedomain.StatusActive { if err := validateResourceShopResource(item.Resource); err != nil { return resourcedomain.ResourceShopItem{}, err } } nowMs := time.Now().UnixMilli() result, err := tx.ExecContext(ctx, ` UPDATE resource_shop_items SET status = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND shop_item_id = ?`, command.Status, command.OperatorUserID, nowMs, command.AppCode, command.ShopItemID, ) if err != nil { return resourcedomain.ResourceShopItem{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.ResourceShopItem{}, err } else if affected == 0 { return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") } if err := tx.Commit(); err != nil { return resourcedomain.ResourceShopItem{}, err } return r.getResourceShopItem(ctx, command.ShopItemID) } // PurchaseResourceShopItem 是 App 道具商店的唯一购买入口;扣金币、写订单和发资源权益必须共享同一事务。 func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) { if r == nil || r.db == nil { return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command.CommandID = strings.TrimSpace(command.CommandID) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } defer func() { _ = tx.Rollback() }() requestHash := resourceShopPurchaseRequestHash(command) if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists { if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) } nowMs := time.Now().UnixMilli() item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := validatePurchasableResourceShopItem(item, nowMs); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if account.AvailableAmount < item.CoinPrice { return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance") } transactionID := transactionID(command.AppCode, command.CommandID) orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID) grantID := resourceGrantID(command.AppCode, command.CommandID) durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond) coinBalanceAfter := account.AvailableAmount - item.CoinPrice metadata := map[string]any{ "app_code": command.AppCode, "order_id": orderID, "user_id": command.UserID, "shop_item_id": item.ShopItemID, "resource_id": item.ResourceID, "resource_code": item.Resource.ResourceCode, "resource_type": item.Resource.ResourceType, "duration_days": item.DurationDays, "duration_ms": durationMS, "price_type": item.PriceType, "coin_spent": item.CoinPrice, "balance_after": coinBalanceAfter, "resource_grant_id": grantID, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.UserID, AssetType: ledger.AssetCoin, AvailableDelta: -item.CoinPrice, FrozenDelta: 0, AvailableAfter: coinBalanceAfter, FrozenAfter: account.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if grantItem.EntitlementID == "" { // 道具商店只出售权益类资源;如果配置成钱包入账资源,必须阻断而不是产生无背包道具的订单。 return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement") } if _, err := tx.ExecContext(ctx, ` INSERT INTO resource_shop_purchase_orders ( app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin, status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`, command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice, transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs, ); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs), { EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset), EventType: "ResourceShopItemPurchased", TransactionID: transactionID, CommandID: command.CommandID, UserID: command.UserID, AssetType: resourceOutboxAsset, Payload: metadata, CreatedAtMS: nowMs, }, }); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := tx.Commit(); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } return resourcedomain.ResourceShopPurchaseReceipt{ OrderID: orderID, TransactionID: transactionID, ShopItem: item, Resource: resource, Balance: ledger.AssetBalance{ AppCode: command.AppCode, UserID: command.UserID, AssetType: ledger.AssetCoin, AvailableAmount: coinBalanceAfter, FrozenAmount: account.FrozenAmount, Version: account.Version + 1, }, CoinSpent: item.CoinPrice, GrantID: grantID, }, nil } func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) { if r == nil || r.db == nil { return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) grant, err := scanResourceGrant(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found") } if err != nil { return resourcedomain.ResourceGrant{}, err } grant.Items, err = r.listResourceGrantItems(ctx, grantID) return grant, err } func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomain.GiftConfigCommand, update bool) (resourcedomain.GiftConfig, error) { if r == nil || r.db == nil { return resourcedomain.GiftConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeGiftConfigCommand(command) if command.ResourceID <= 0 { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config command is incomplete") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.GiftConfig{}, err } defer func() { _ = tx.Rollback() }() resource, err := r.getResourceForUpdate(ctx, tx, command.ResourceID) if err != nil { return resourcedomain.GiftConfig{}, err } if resource.ResourceType != resourcedomain.TypeGift { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource") } command = applyResourcePricingToGiftCommand(command, resource) if resourcedomain.NormalizePriceType(resource.PriceType) == "" && command.CoinPrice <= 0 { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift price is invalid") } if err := validateGiftConfigCommand(command); err != nil { return resourcedomain.GiftConfig{}, err } if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive { return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled") } if err := r.ensureDefaultGiftTypeConfigs(ctx, tx); err != nil { return resourcedomain.GiftConfig{}, err } if ok, err := r.giftTypeConfigExistsTx(ctx, tx, command.GiftTypeCode); err != nil { return resourcedomain.GiftConfig{}, err } else if !ok { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift type is not configured") } nowMs := time.Now().UnixMilli() // CP 关系类型存放在 presentation_json 内,保存前统一合并,避免 gift_configs 为单个业务展示字段扩表。 presentation := giftPresentationWithCPRelationType(command.PresentationJSON, command.CPRelationType) effectTypesJSON, err := json.Marshal(command.EffectTypes) if err != nil { return resourcedomain.GiftConfig{}, err } if !update { if _, err := tx.ExecContext(ctx, ` INSERT INTO gift_configs ( app_code, gift_id, resource_id, status, name, sort_order, presentation_json, gift_type_code, effective_from_ms, effective_to_ms, effect_types_json, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), command.OperatorUserID, command.OperatorUserID, nowMs, nowMs, ); err != nil { return resourcedomain.GiftConfig{}, err } } else { result, err := tx.ExecContext(ctx, ` UPDATE gift_configs SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?, gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND gift_id = ?`, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation, command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON), command.OperatorUserID, nowMs, command.AppCode, command.GiftID, ) if err != nil { return resourcedomain.GiftConfig{}, err } if affected, err := result.RowsAffected(); err != nil { return resourcedomain.GiftConfig{}, err } else if affected == 0 { return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") } } if err := r.upsertGiftPriceTx(ctx, tx, command, nowMs); err != nil { return resourcedomain.GiftConfig{}, err } if err := r.replaceGiftConfigRegionsTx(ctx, tx, command.GiftID, command.RegionIDs, nowMs); err != nil { return resourcedomain.GiftConfig{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ resourceOutboxEvent("GiftConfigChanged", fmt.Sprintf("gift:%s:config:%d", command.GiftID, nowMs), 0, command.ResourceID, command, nowMs), }); err != nil { return resourcedomain.GiftConfig{}, err } if err := tx.Commit(); err != nil { return resourcedomain.GiftConfig{}, err } return r.getGiftConfig(ctx, command.GiftID) } func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) { if quantity <= 0 { return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive") } snapshot, err := json.Marshal(resource) if err != nil { return resourcedomain.ResourceGrantItem{}, err } item := resourcedomain.ResourceGrantItem{ GrantID: grantID, ResourceID: resource.ResourceID, ResourceSnapshotJSON: string(snapshot), Quantity: quantity, DurationMS: durationMS, CreatedAtMS: nowMs, } if resourcedomain.IsWalletCredit(resource) { assetType := resourcedomain.NormalizeWalletAssetType(resource.WalletAssetType) amount, err := checkedMul(resource.WalletAssetAmount, quantity) if err != nil { return resourcedomain.ResourceGrantItem{}, err } account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) if err != nil { return resourcedomain.ResourceGrantItem{}, err } walletCommandID := fmt.Sprintf("resource_grant:%s:%d", commandID, resource.ResourceID) transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) metadata := map[string]any{ "app_code": appcode.FromContext(ctx), "grant_id": grantID, "command_id": commandID, "target_user_id": targetUserID, "resource_id": resource.ResourceID, "resource_code": resource.ResourceCode, "resource_type": resource.ResourceType, "quantity": quantity, "wallet_amount": amount, "wallet_asset": assetType, "operator_reason": "resource grant", } if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, resourceWalletCreditHash(grantID, resource.ResourceID, quantity), grantID, metadata, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err } if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err } availableAfter := account.AvailableAmount + amount if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: targetUserID, AssetType: assetType, AvailableDelta: amount, FrozenDelta: 0, AvailableAfter: availableAfter, FrozenAfter: account.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return resourcedomain.ResourceGrantItem{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, amount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), }); err != nil { return resourcedomain.ResourceGrantItem{}, err } item.ResultType = resourcedomain.ResultWalletCredit item.WalletTransactionID = transactionID } else { entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs) if err != nil { return resourcedomain.ResourceGrantItem{}, err } item.ResultType = resourcedomain.ResultEntitlement item.EntitlementID = entitlementID } inserted, err := r.insertResourceGrantItem(ctx, tx, item) if err != nil { return resourcedomain.ResourceGrantItem{}, err } return inserted, nil } func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, groupItem resourcedomain.ResourceGroupItem, nowMs int64) (resourcedomain.ResourceGrantItem, error) { assetType := resourcedomain.NormalizeWalletAssetType(groupItem.WalletAssetType) if !validGroupWalletAssetType(assetType) || groupItem.WalletAssetAmount <= 0 { return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") } snapshot, err := json.Marshal(map[string]any{ "group_item_id": groupItem.GroupItemID, "group_id": groupItem.GroupID, "item_type": resourcedomain.GroupItemTypeWalletAsset, "wallet_asset_type": assetType, "wallet_asset_amount": groupItem.WalletAssetAmount, "sort_order": groupItem.SortOrder, }) if err != nil { return resourcedomain.ResourceGrantItem{}, err } account, err := r.lockAccount(ctx, tx, targetUserID, assetType, true, nowMs) if err != nil { return resourcedomain.ResourceGrantItem{}, err } walletCommandID := fmt.Sprintf("resource_group_grant:%s:item:%d:asset:%s", commandID, groupItem.GroupItemID, assetType) transactionID := transactionID(appcode.FromContext(ctx), walletCommandID) metadata := map[string]any{ "app_code": appcode.FromContext(ctx), "grant_id": grantID, "command_id": commandID, "target_user_id": targetUserID, "group_id": groupItem.GroupID, "group_item_id": groupItem.GroupItemID, "item_type": resourcedomain.GroupItemTypeWalletAsset, "wallet_amount": groupItem.WalletAssetAmount, "wallet_asset": assetType, "operator_reason": "resource group grant", } if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrant, groupWalletAssetCreditHash(grantID, groupItem.GroupItemID, assetType, groupItem.WalletAssetAmount), grantID, metadata, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err } if err := r.applyAccountDelta(ctx, tx, account, groupItem.WalletAssetAmount, 0, nowMs); err != nil { return resourcedomain.ResourceGrantItem{}, err } availableAfter := account.AvailableAmount + groupItem.WalletAssetAmount if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: targetUserID, AssetType: assetType, AvailableDelta: groupItem.WalletAssetAmount, FrozenDelta: 0, AvailableAfter: availableAfter, FrozenAfter: account.FrozenAmount, CreatedAtMS: nowMs, }); err != nil { return resourcedomain.ResourceGrantItem{}, err } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, walletCommandID, targetUserID, assetType, groupItem.WalletAssetAmount, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), }); err != nil { return resourcedomain.ResourceGrantItem{}, err } inserted, err := r.insertResourceGrantItem(ctx, tx, resourcedomain.ResourceGrantItem{ GrantID: grantID, ResourceID: 0, ResourceSnapshotJSON: string(snapshot), Quantity: groupItem.WalletAssetAmount, DurationMS: 0, ResultType: resourcedomain.ResultWalletCredit, WalletTransactionID: transactionID, CreatedAtMS: nowMs, }) if err != nil { return resourcedomain.ResourceGrantItem{}, err } return inserted, nil } func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64) (string, error) { expiresAt := int64(0) if durationMS > 0 { if durationMS > math.MaxInt64-nowMs { return "", xerr.New(xerr.InvalidArgument, "duration overflow") } expiresAt = nowMs + durationMS } strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag { existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, nowMs) if err != nil { return "", err } if exists { newQuantity := existing.Quantity newRemaining := existing.RemainingQuantity newExpiresAt := existing.ExpiresAtMS switch strategy { case resourcedomain.GrantStrategyExtendExpiry: newRemaining += quantity newQuantity += quantity if durationMS > 0 { base := max(existing.ExpiresAtMS, nowMs) if durationMS > math.MaxInt64-base { return "", xerr.New(xerr.InvalidArgument, "duration overflow") } newExpiresAt = base + durationMS } else { newExpiresAt = 0 } case resourcedomain.GrantStrategyIncreaseQuantity: newQuantity += quantity newRemaining += quantity case resourcedomain.GrantStrategySetActiveFlag: newQuantity = 1 newRemaining = 1 newExpiresAt = expiresAt } if _, err := tx.ExecContext(ctx, ` UPDATE user_resource_entitlements SET status = 'active', quantity = ?, remaining_quantity = ?, expires_at_ms = ?, source_grant_id = ?, updated_at_ms = ? WHERE app_code = ? AND entitlement_id = ?`, newQuantity, newRemaining, newExpiresAt, grantID, nowMs, appcode.FromContext(ctx), existing.EntitlementID, ); err != nil { return "", err } return existing.EntitlementID, nil } } entitlementID := entitlementID(appcode.FromContext(ctx), grantID, resource.ResourceID, nowMs) if _, err := tx.ExecContext(ctx, ` INSERT INTO user_resource_entitlements ( app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity, effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), entitlementID, userID, resource.ResourceID, quantity, quantity, nowMs, expiresAt, grantID, nowMs, nowMs, ); err != nil { return "", err } return entitlementID, nil } func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error { if strings.TrimSpace(entitlementID) == "" { return nil } _, err := tx.ExecContext(ctx, ` UPDATE user_resource_entitlements SET expires_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND entitlement_id = ?`, expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID, ) return err } func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) { row := tx.QueryRowContext(ctx, ` SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id, e.created_at_ms, e.updated_at_ms, FALSE AS equipped, `+resourceColumnsWithAlias("r")+` FROM user_resource_entitlements e JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC LIMIT 1 FOR UPDATE`, appcode.FromContext(ctx), userID, resourceID, nowMs, nowMs, ) item, err := scanUserResourceEntitlement(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.UserResourceEntitlement{}, false, nil } return item, err == nil, err } func (r *Repository) queryEquipableEntitlementForUpdate(ctx context.Context, tx *sql.Tx, command resourcedomain.EquipUserResourceCommand, nowMs int64) (resourcedomain.UserResourceEntitlement, error) { where := ` WHERE e.app_code = ? AND e.user_id = ? AND e.resource_id = ? AND e.status = 'active' AND e.effective_at_ms <= ? AND (e.expires_at_ms = 0 OR e.expires_at_ms > ?) AND e.remaining_quantity > 0 AND r.status = 'active'` args := []any{appcode.FromContext(ctx), command.UserID, command.ResourceID, nowMs, nowMs} if command.EntitlementID != "" { where += ` AND e.entitlement_id = ?` args = append(args, command.EntitlementID) } query := userResourceSelectSQL() + where + ` ORDER BY e.expires_at_ms DESC, e.created_at_ms DESC LIMIT 1 FOR UPDATE` item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, query, args...)) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") } return item, err } func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) { result, err := tx.ExecContext(ctx, ` INSERT INTO resource_grant_items ( app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), item.GrantID, item.ResourceID, item.ResourceSnapshotJSON, item.Quantity, item.DurationMS, item.ResultType, item.WalletTransactionID, item.EntitlementID, item.CreatedAtMS, ) if err != nil { return resourcedomain.ResourceGrantItem{}, err } id, err := result.LastInsertId() if err != nil { return resourcedomain.ResourceGrantItem{}, err } item.GrantItemID = id return item, nil } func (r *Repository) insertResourceGrant(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, grantSource string, subjectType string, subjectID string, requestHash string, groupSnapshotJSON string, reason string, operatorUserID int64, nowMs int64) error { var snapshot any if strings.TrimSpace(groupSnapshotJSON) == "" { snapshot = nil } else { snapshot = groupSnapshotJSON } _, err := tx.ExecContext(ctx, ` INSERT INTO resource_grants ( app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, grant_subject_id, status, request_hash, group_snapshot_json, reason, operator_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), grantID, commandID, targetUserID, grantSource, subjectType, subjectID, resourcedomain.GrantStatusDone, requestHash, snapshot, reason, operatorUserID, nowMs, nowMs, ) return err } func (r *Repository) lookupResourceGrant(ctx context.Context, tx *sql.Tx, commandID string, requestHash string) (string, bool, error) { row := tx.QueryRowContext(ctx, `SELECT grant_id, request_hash FROM resource_grants WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), commandID, ) var grantID string var storedHash string if err := row.Scan(&grantID, &storedHash); err != nil { if errors.Is(err, sql.ErrNoRows) { return "", false, nil } return "", false, err } if storedHash != requestHash { return "", true, xerr.New(xerr.LedgerConflict, "resource grant idempotency conflict") } return grantID, true, nil } func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) { row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ?`, appcode.FromContext(ctx), grantID) grant, err := scanResourceGrant(row) if err != nil { return resourcedomain.ResourceGrant{}, err } grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grantID) return grant, err } func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) { return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID) } func (r *Repository) listResourceGrantItemsTx(ctx context.Context, tx *sql.Tx, grantID string) ([]resourcedomain.ResourceGrantItem, error) { return r.listResourceGrantItemsWithQuery(ctx, tx, grantID) } func (r *Repository) listResourceGrantItemsWithQuery(ctx context.Context, querier interface { QueryContext(context.Context, string, ...any) (*sql.Rows, error) }, grantID string) ([]resourcedomain.ResourceGrantItem, error) { rows, err := querier.QueryContext(ctx, ` SELECT grant_item_id, grant_id, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'), quantity, duration_ms, result_type, wallet_transaction_id, entitlement_id, created_at_ms FROM resource_grant_items WHERE app_code = ? AND grant_id = ? ORDER BY grant_item_id ASC`, appcode.FromContext(ctx), grantID, ) if err != nil { return nil, err } defer rows.Close() items := make([]resourcedomain.ResourceGrantItem, 0) for rows.Next() { var item resourcedomain.ResourceGrantItem if err := rows.Scan(&item.GrantItemID, &item.GrantID, &item.ResourceID, &item.ResourceSnapshotJSON, &item.Quantity, &item.DurationMS, &item.ResultType, &item.WalletTransactionID, &item.EntitlementID, &item.CreatedAtMS); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (r *Repository) upsertGiftPriceTx(ctx context.Context, tx *sql.Tx, command resourcedomain.GiftConfigCommand, nowMs int64) error { legacyHeatValue := command.CoinPrice _, err := tx.ExecContext(ctx, ` INSERT INTO wallet_gift_prices ( app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value, effective_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), charge_asset_type = VALUES(charge_asset_type), coin_price = VALUES(coin_price), gift_point_amount = VALUES(gift_point_amount), heat_value = VALUES(heat_value), effective_at_ms = VALUES(effective_at_ms), updated_at_ms = VALUES(updated_at_ms)`, appcode.FromContext(ctx), command.GiftID, command.PriceVersion, command.ChargeAssetType, command.CoinPrice, command.GiftPointAmount, legacyHeatValue, command.EffectiveAtMS, nowMs, nowMs, ) return err } func (r *Repository) latestGiftPrice(ctx context.Context, querier sqlRowQuerier, giftID string, nowMs int64) (giftPrice, error) { row := querier.QueryRowContext(ctx, `SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value FROM wallet_gift_prices WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ? ORDER BY effective_at_ms DESC, price_version DESC LIMIT 1`, appcode.FromContext(ctx), giftID, nowMs, ) var price giftPrice if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil { if errors.Is(err, sql.ErrNoRows) { return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active") } return giftPrice{}, err } price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) return price, nil } func (r *Repository) getGiftConfig(ctx context.Context, giftID string) (resourcedomain.GiftConfig, error) { row := r.db.QueryRowContext(ctx, giftConfigSelectSQL()+`WHERE gc.app_code = ? AND gc.gift_id = ?`, appcode.FromContext(ctx), giftID) gift, err := scanGiftConfig(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config not found") } if err != nil { return resourcedomain.GiftConfig{}, err } price, priceErr := r.latestGiftPrice(ctx, r.db, gift.GiftID, time.Now().UnixMilli()) if priceErr == nil { gift.PriceVersion = price.PriceVersion gift.ChargeAssetType = price.ChargeAssetType gift.CoinPrice = price.CoinPrice // gift_point_amount 是历史列,后台和送礼主链路只暴露真实 COIN 价格。 gift.GiftPointAmount = 0 // heat_value 是旧配置列,后台不再读写;对兼容调用方只回真实价格,房间贡献也按真实扣费结算。 gift.HeatValue = price.CoinPrice } gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, r.db, gift.GiftID) if err != nil { return resourcedomain.GiftConfig{}, err } return gift, nil } func (r *Repository) getGiftTypeConfigTx(ctx context.Context, tx *sql.Tx, typeCode string) (resourcedomain.GiftTypeConfig, error) { row := tx.QueryRowContext(ctx, giftTypeConfigSelectSQL()+`WHERE app_code = ? AND type_code = ?`, appcode.FromContext(ctx), typeCode) item, err := scanGiftTypeConfig(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.GiftTypeConfig{}, xerr.New(xerr.NotFound, "gift type config not found") } return item, err } func (r *Repository) giftTypeConfigExistsTx(ctx context.Context, tx *sql.Tx, typeCode string) (bool, error) { var exists int err := tx.QueryRowContext(ctx, ` SELECT 1 FROM gift_type_configs WHERE app_code = ? AND type_code = ? LIMIT 1`, appcode.FromContext(ctx), typeCode).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } func (r *Repository) ensureDefaultGiftTypeConfigs(ctx context.Context, execer sqlExecer) error { nowMs := time.Now().UnixMilli() for _, item := range resourcedomain.DefaultGiftTypeConfigs(appcode.FromContext(ctx)) { if _, err := execer.ExecContext(ctx, ` INSERT IGNORE INTO gift_type_configs ( app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`, item.AppCode, item.TypeCode, item.Name, item.TabKey, item.Status, item.SortOrder, nowMs, nowMs, ); err != nil { return err } } return nil } func (r *Repository) resolveActiveGiftConfig(ctx context.Context, tx *sql.Tx, giftID string, regionID int64) (resourcedomain.GiftConfig, error) { nowMs := time.Now().UnixMilli() row := tx.QueryRowContext(ctx, giftConfigSelectSQL()+` WHERE gc.app_code = ? AND gc.gift_id = ? AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?) AND EXISTS ( SELECT 1 FROM gift_config_regions gcr WHERE gcr.app_code = gc.app_code AND gcr.gift_id = gc.gift_id AND (gcr.region_id = 0 OR gcr.region_id = ?) ) FOR UPDATE`, appcode.FromContext(ctx), giftID, nowMs, nowMs, regionID, ) gift, err := scanGiftConfig(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.GiftConfig{}, xerr.New(xerr.NotFound, "gift config is not active") } if err != nil { return resourcedomain.GiftConfig{}, err } gift.RegionIDs, err = r.listGiftConfigRegionIDs(ctx, tx, gift.GiftID) return gift, err } func (r *Repository) getResourceForUpdate(ctx context.Context, tx *sql.Tx, resourceID int64) (resourcedomain.Resource, error) { row := tx.QueryRowContext(ctx, resourceSelectSQL()+` WHERE app_code = ? AND resource_id = ? FOR UPDATE`, appcode.FromContext(ctx), resourceID) resource, err := scanResource(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.Resource{}, xerr.New(xerr.NotFound, "resource not found") } return resource, err } func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) { return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false) } func (r *Repository) getResourceShopItemTx(ctx context.Context, tx *sql.Tx, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { return r.getResourceShopItemWithQuerier(ctx, tx, shopItemID, lock) } func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier sqlRowQuerier, shopItemID int64, lock bool) (resourcedomain.ResourceShopItem, error) { query := resourceShopItemSelectSQL() + ` WHERE si.app_code = ? AND si.shop_item_id = ?` if lock { query += ` FOR UPDATE` } row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), shopItemID) item, err := scanResourceShopItem(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.ResourceShopItem{}, xerr.New(xerr.NotFound, "resource shop item not found") } return item, err } func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) { item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx, userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`, appcode.FromContext(ctx), entitlementID, )) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found") } return item, err } func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) { var orderID string var shopItemID int64 var priceCoin int64 var grantID string var entitlementID string err := tx.QueryRowContext(ctx, ` SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id FROM resource_shop_purchase_orders WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`, appcode.FromContext(ctx), transactionID, userID, ).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } else if exists { balance.Version = account.Version } return resourcedomain.ResourceShopPurchaseReceipt{ OrderID: orderID, TransactionID: transactionID, ShopItem: item, Resource: resource, Balance: balance, CoinSpent: priceCoin, GrantID: grantID, }, nil } func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) { resourceIDs = compactPositiveInt64s(resourceIDs) if len(resourceIDs) == 0 { return []resourcedomain.ResourceShopItem{}, nil } args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...) rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+` WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`) ORDER BY si.sort_order ASC, si.shop_item_id DESC`, args..., ) if err != nil { return nil, err } defer rows.Close() items := make([]resourcedomain.ResourceShopItem, 0, len(resourceIDs)) for rows.Next() { item, err := scanResourceShopItem(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (r *Repository) getResourceGroup(ctx context.Context, querier sqlRowQuerier, groupID int64, lock bool) (resourcedomain.ResourceGroup, error) { query := ` SELECT app_code, group_id, group_code, name, status, description, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms FROM resource_groups WHERE app_code = ? AND group_id = ?` if lock { query += ` FOR UPDATE` } row := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), groupID) group, err := scanResourceGroup(row) if errors.Is(err, sql.ErrNoRows) { return resourcedomain.ResourceGroup{}, xerr.New(xerr.NotFound, "resource group not found") } return group, err } func (r *Repository) listResourceGroupItems(ctx context.Context, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { return r.listResourceGroupItemsWithQuery(ctx, r.db, groupID) } func (r *Repository) listResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, lock bool) ([]resourcedomain.ResourceGroupItem, error) { if lock { if err := r.lockResourceGroupItemRows(ctx, tx, groupID); err != nil { return nil, err } } items, err := r.listResourceGroupItemsWithQuery(ctx, tx, groupID) if err != nil || !lock { return items, err } for index := range items { if resourcedomain.NormalizeGroupItemType(items[index].ItemType) != resourcedomain.GroupItemTypeResource { continue } resource, err := r.getResourceForUpdate(ctx, tx, items[index].ResourceID) if err != nil { return nil, err } items[index].Resource = resource } return items, nil } func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querier interface { QueryContext(context.Context, string, ...any) (*sql.Rows, error) }, groupID int64) ([]resourcedomain.ResourceGroupItem, error) { query := ` SELECT i.group_item_id, i.group_id, i.item_type, i.resource_id, i.wallet_asset_type, i.wallet_asset_amount, ` + nullableResourceColumnsWithAlias("r") + `, i.quantity, i.duration_ms, i.sort_order, i.created_at_ms, i.updated_at_ms FROM resource_group_items i LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id WHERE i.app_code = ? AND i.group_id = ? ORDER BY i.sort_order ASC, i.group_item_id ASC` rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID) if err != nil { return nil, err } defer rows.Close() items := make([]resourcedomain.ResourceGroupItem, 0) for rows.Next() { item, err := scanResourceGroupItem(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx, groupID int64) error { rows, err := tx.QueryContext(ctx, ` SELECT group_item_id FROM resource_group_items WHERE app_code = ? AND group_id = ? ORDER BY sort_order ASC, group_item_id ASC FOR UPDATE`, appcode.FromContext(ctx), groupID, ) if err != nil { return err } defer rows.Close() for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return err } } return rows.Err() } func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx, groupID int64, items []resourcedomain.ResourceGroupItemInput, requireGrantable bool, nowMs int64) error { seenResources := make(map[int64]struct{}, len(items)) seenWalletAssets := make(map[string]struct{}, len(items)) if _, err := tx.ExecContext(ctx, `DELETE FROM resource_group_items WHERE app_code = ? AND group_id = ?`, appcode.FromContext(ctx), groupID); err != nil { return err } for _, item := range items { item = normalizeResourceGroupItemInput(item) switch item.ItemType { case resourcedomain.GroupItemTypeResource: if item.ResourceID <= 0 || item.Quantity <= 0 { return xerr.New(xerr.InvalidArgument, "resource group item is invalid") } if _, ok := seenResources[item.ResourceID]; ok { return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") } seenResources[item.ResourceID] = struct{}{} resource, err := r.getResourceForUpdate(ctx, tx, item.ResourceID) if err != nil { return err } if requireGrantable { if err := validateActiveResourceGroupResource(resource); err != nil { return err } } if _, err := tx.ExecContext(ctx, ` INSERT INTO resource_group_items ( app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, quantity, duration_ms, sort_order, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), groupID, item.ItemType, item.ResourceID, "", int64(0), item.Quantity, item.DurationMS, item.SortOrder, nowMs, nowMs, ); err != nil { return err } case resourcedomain.GroupItemTypeWalletAsset: if !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") } if _, ok := seenWalletAssets[item.WalletAssetType]; ok { return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") } seenWalletAssets[item.WalletAssetType] = struct{}{} if _, err := tx.ExecContext(ctx, ` INSERT INTO resource_group_items ( app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount, quantity, duration_ms, sort_order, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), groupID, item.ItemType, int64(0), item.WalletAssetType, item.WalletAssetAmount, int64(1), int64(0), item.SortOrder, nowMs, nowMs, ); err != nil { return err } default: return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") } } return nil } func (r *Repository) replaceGiftConfigRegionsTx(ctx context.Context, tx *sql.Tx, giftID string, regionIDs []int64, nowMs int64) error { if _, err := tx.ExecContext(ctx, `DELETE FROM gift_config_regions WHERE app_code = ? AND gift_id = ?`, appcode.FromContext(ctx), giftID); err != nil { return err } for _, regionID := range regionIDs { if regionID < 0 { return xerr.New(xerr.InvalidArgument, "gift region is invalid") } if _, err := tx.ExecContext(ctx, ` INSERT INTO gift_config_regions ( app_code, gift_id, region_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?)`, appcode.FromContext(ctx), giftID, regionID, nowMs, nowMs, ); err != nil { return err } } return nil } func (r *Repository) listGiftConfigRegionIDs(ctx context.Context, querier sqlQuerier, giftID string) ([]int64, error) { rows, err := querier.QueryContext(ctx, ` SELECT region_id FROM gift_config_regions WHERE app_code = ? AND gift_id = ? ORDER BY region_id ASC`, appcode.FromContext(ctx), giftID, ) if err != nil { return nil, err } defer rows.Close() regionIDs := make([]int64, 0) for rows.Next() { var regionID int64 if err := rows.Scan(®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, &resource.PriceType, &resource.CoinPrice, &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.MetadataJSON, &resource.SortOrder, &resource.CreatedByUserID, &resource.UpdatedByUserID, &resource.CreatedAtMS, &resource.UpdatedAtMS, ); err != nil { return resourcedomain.Resource{}, err } resource.UsageScopes = parseStringArray(scopesJSON) return resource, nil } func scanResourceShopItem(scanner scanTarget) (resourcedomain.ResourceShopItem, error) { var item resourcedomain.ResourceShopItem var resource resourcedomain.Resource var scopesJSON string if err := scanner.Scan( &item.AppCode, &item.ShopItemID, &item.ResourceID, &item.Status, &item.DurationDays, &item.PriceType, &item.CoinPrice, &item.EffectiveFromMS, &item.EffectiveToMS, &item.SortOrder, &item.CreatedByUserID, &item.UpdatedByUserID, &item.CreatedAtMS, &item.UpdatedAtMS, &resource.AppCode, &resource.ResourceID, &resource.ResourceCode, &resource.ResourceType, &resource.Name, &resource.Status, &resource.Grantable, &resource.ManagerGrantEnabled, &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, &resource.PriceType, &resource.CoinPrice, &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.MetadataJSON, &resource.SortOrder, &resource.CreatedByUserID, &resource.UpdatedByUserID, &resource.CreatedAtMS, &resource.UpdatedAtMS, ); err != nil { return resourcedomain.ResourceShopItem{}, err } resource.UsageScopes = parseStringArray(scopesJSON) item.Resource = resource return item, nil } func scanResourceGroup(scanner scanTarget) (resourcedomain.ResourceGroup, error) { var group resourcedomain.ResourceGroup err := scanner.Scan( &group.AppCode, &group.GroupID, &group.GroupCode, &group.Name, &group.Status, &group.Description, &group.SortOrder, &group.CreatedByUserID, &group.UpdatedByUserID, &group.CreatedAtMS, &group.UpdatedAtMS, ) return group, err } func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem, error) { var item resourcedomain.ResourceGroupItem var resource resourcedomain.Resource var scopesJSON string if err := scanner.Scan( &item.GroupItemID, &item.GroupID, &item.ItemType, &item.ResourceID, &item.WalletAssetType, &item.WalletAssetAmount, &resource.AppCode, &resource.ResourceID, &resource.ResourceCode, &resource.ResourceType, &resource.Name, &resource.Status, &resource.Grantable, &resource.ManagerGrantEnabled, &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, &resource.PriceType, &resource.CoinPrice, &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.MetadataJSON, &resource.SortOrder, &resource.CreatedByUserID, &resource.UpdatedByUserID, &resource.CreatedAtMS, &resource.UpdatedAtMS, &item.Quantity, &item.DurationMS, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS, ); err != nil { return resourcedomain.ResourceGroupItem{}, err } resource.UsageScopes = parseStringArray(scopesJSON) item.Resource = resource return item, nil } func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) { var gift resourcedomain.GiftConfig var resource resourcedomain.Resource var scopesJSON string var effectTypesJSON string if err := scanner.Scan( &gift.AppCode, &gift.GiftID, &gift.ResourceID, &gift.Status, &gift.Name, &gift.SortOrder, &gift.PresentationJSON, &gift.GiftTypeCode, &gift.EffectiveFromMS, &gift.EffectiveToMS, &effectTypesJSON, &gift.CreatedByUserID, &gift.UpdatedByUserID, &gift.CreatedAtMS, &gift.UpdatedAtMS, &resource.AppCode, &resource.ResourceID, &resource.ResourceCode, &resource.ResourceType, &resource.Name, &resource.Status, &resource.Grantable, &resource.ManagerGrantEnabled, &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, &resource.PriceType, &resource.CoinPrice, &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.MetadataJSON, &resource.SortOrder, &resource.CreatedByUserID, &resource.UpdatedByUserID, &resource.CreatedAtMS, &resource.UpdatedAtMS, ); err != nil { return resourcedomain.GiftConfig{}, err } resource.UsageScopes = parseStringArray(scopesJSON) gift.Resource = resource gift.EffectTypes = parseStringArray(effectTypesJSON) // CPRelationType 是展示 JSON 的结构化投影,gift panel 和送礼回执不再让调用方自己解析 JSON。 gift.CPRelationType = cpRelationTypeFromPresentationJSON(gift.PresentationJSON) return gift, nil } func scanGiftTypeConfig(scanner scanTarget) (resourcedomain.GiftTypeConfig, error) { var item resourcedomain.GiftTypeConfig err := scanner.Scan( &item.AppCode, &item.TypeCode, &item.Name, &item.TabKey, &item.Status, &item.SortOrder, &item.CreatedByUserID, &item.UpdatedByUserID, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourceEntitlement, error) { var item resourcedomain.UserResourceEntitlement var resource resourcedomain.Resource var scopesJSON string if err := scanner.Scan( &item.AppCode, &item.EntitlementID, &item.UserID, &item.ResourceID, &item.Status, &item.Quantity, &item.RemainingQuantity, &item.EffectiveAtMS, &item.ExpiresAtMS, &item.SourceGrantID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.Equipped, &resource.AppCode, &resource.ResourceID, &resource.ResourceCode, &resource.ResourceType, &resource.Name, &resource.Status, &resource.Grantable, &resource.ManagerGrantEnabled, &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, &resource.PriceType, &resource.CoinPrice, &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, &resource.AnimationURL, &resource.MetadataJSON, &resource.SortOrder, &resource.CreatedByUserID, &resource.UpdatedByUserID, &resource.CreatedAtMS, &resource.UpdatedAtMS, ); err != nil { return resourcedomain.UserResourceEntitlement{}, err } resource.UsageScopes = parseStringArray(scopesJSON) item.Resource = resource return item, nil } func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error) { var grant resourcedomain.ResourceGrant err := scanner.Scan( &grant.AppCode, &grant.GrantID, &grant.CommandID, &grant.TargetUserID, &grant.GrantSource, &grant.GrantSubjectType, &grant.GrantSubjectID, &grant.Status, &grant.Reason, &grant.OperatorUserID, &grant.CreatedAtMS, &grant.UpdatedAtMS, ) return grant, err } func resourceSelectSQL() string { return `SELECT ` + resourceColumnsWithAlias("") + ` FROM resources` } func resourceColumnsWithAlias(alias string) string { prefix := "" if alias != "" { prefix = alias + "." } return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` + prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` + prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` + prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms` } func nullableResourceColumnsWithAlias(alias string) string { prefix := "" if alias != "" { prefix = alias + "." } return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` + `COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` + `COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` + `COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + `COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` + `COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` + `COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` + `COALESCE(` + prefix + `created_at_ms, 0), COALESCE(` + prefix + `updated_at_ms, 0)` } func giftConfigSelectSQL() string { return ` SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order, COALESCE(CAST(gc.presentation_json AS CHAR), '{}'), COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0), COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'), gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms, ` + resourceColumnsWithAlias("r") + ` FROM gift_configs gc JOIN resources r ON r.app_code = gc.app_code AND r.resource_id = gc.resource_id ` } func giftTypeConfigSelectSQL() string { return ` SELECT app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms FROM gift_type_configs ` } func resourceShopItemSelectSQL() string { return ` SELECT si.app_code, si.shop_item_id, si.resource_id, si.status, si.duration_days, r.price_type, CASE WHEN r.price_type = 'coin' THEN r.coin_price * si.duration_days ELSE 0 END AS coin_price, si.effective_from_ms, si.effective_to_ms, si.sort_order, si.created_by_user_id, si.updated_by_user_id, si.created_at_ms, si.updated_at_ms, ` + resourceColumnsWithAlias("r") + ` FROM resource_shop_items si JOIN resources r ON r.app_code = si.app_code AND r.resource_id = si.resource_id ` } func userResourceSelectSQL() string { return ` SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.source_grant_id, e.created_at_ms, e.updated_at_ms, CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped, ` + resourceColumnsWithAlias("r") + ` FROM user_resource_entitlements e JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code AND eq.user_id = e.user_id AND eq.resource_type = r.resource_type AND eq.entitlement_id = e.entitlement_id ` } func resourceGrantSelectSQL() string { return ` SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms FROM resource_grants ` } func resourceWhereSQL(query resourcedomain.ListResourcesQuery) (string, []any) { where := `WHERE app_code = ?` args := []any{query.AppCode} if query.ManagerGrantOnly { where += ` AND status = 'active' AND grantable = TRUE AND manager_grant_enabled = TRUE` } else if query.ActiveOnly { where += ` AND status = 'active'` } else if query.Status != "" { where += ` AND status = ?` args = append(args, query.Status) } if query.ResourceType != "" { where += ` AND resource_type = ?` args = append(args, query.ResourceType) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (resource_code LIKE ? OR name LIKE ?)` args = append(args, like, like) } return where, args } func resourceGroupWhereSQL(query resourcedomain.ListResourceGroupsQuery) (string, []any) { where := `WHERE app_code = ?` args := []any{query.AppCode} if query.ActiveOnly { where += ` AND status = 'active'` } else if query.Status != "" { where += ` AND status = ?` args = append(args, query.Status) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (group_code LIKE ? OR name LIKE ?)` args = append(args, like, like) } return where, args } func giftConfigWhereSQL(query resourcedomain.ListGiftConfigsQuery) (string, []any) { where := `WHERE gc.app_code = ?` args := []any{query.AppCode} if query.ActiveOnly { where += ` AND gc.status = 'active' AND r.status = 'active' AND r.resource_type = 'gift' AND (COALESCE(gc.effective_from_ms, 0) = 0 OR gc.effective_from_ms <= ?) AND (COALESCE(gc.effective_to_ms, 0) = 0 OR gc.effective_to_ms > ?)` nowMs := time.Now().UnixMilli() args = append(args, nowMs, nowMs) } else if query.Status != "" { where += ` AND gc.status = ?` args = append(args, query.Status) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (gc.gift_id LIKE ? OR gc.name LIKE ? OR r.resource_code LIKE ?)` args = append(args, like, like, like) } if query.GiftTypeCode != "" { where += ` AND gc.gift_type_code = ?` args = append(args, query.GiftTypeCode) } if query.FilterRegion { where += ` AND EXISTS ( SELECT 1 FROM gift_config_regions gcr WHERE gcr.app_code = gc.app_code AND gcr.gift_id = gc.gift_id AND (gcr.region_id = 0 OR gcr.region_id = ?) )` args = append(args, query.RegionID) } return where, args } func giftTypeConfigWhereSQL(query resourcedomain.ListGiftTypeConfigsQuery) (string, []any) { where := `WHERE app_code = ?` args := []any{query.AppCode} if query.ActiveOnly { where += ` AND status = 'active'` } else if query.Status != "" { where += ` AND status = ?` args = append(args, query.Status) } return where, args } func resourceShopItemsWhereSQL(query resourcedomain.ListResourceShopItemsQuery) (string, []any) { where := `WHERE si.app_code = ? AND r.resource_type IN ('avatar_frame', 'profile_card', 'vehicle', 'chat_bubble', 'badge', 'mic_seat_icon', 'mic_seat_animation')` args := []any{query.AppCode} if query.ActiveOnly { nowMs := time.Now().UnixMilli() where += ` AND si.status = 'active' AND r.status = 'active' AND (si.effective_from_ms = 0 OR si.effective_from_ms <= ?) AND (si.effective_to_ms = 0 OR si.effective_to_ms > ?)` args = append(args, nowMs, nowMs) } else if query.Status != "" { where += ` AND si.status = ?` args = append(args, query.Status) } if query.ResourceType != "" { where += ` AND r.resource_type = ?` args = append(args, query.ResourceType) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (r.resource_code LIKE ? OR r.name LIKE ?)` args = append(args, like, like) } return where, args } func normalizeResourceListQuery(query resourcedomain.ListResourcesQuery) resourcedomain.ListResourcesQuery { query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) if query.ResourceType != "" && !resourcedomain.ValidResourceType(query.ResourceType) { query.ResourceType = "__invalid__" } query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Keyword = strings.TrimSpace(query.Keyword) query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) return query } func normalizeResourceGroupListQuery(query resourcedomain.ListResourceGroupsQuery) resourcedomain.ListResourceGroupsQuery { query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Keyword = strings.TrimSpace(query.Keyword) query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) return query } func normalizeGiftConfigListQuery(query resourcedomain.ListGiftConfigsQuery) resourcedomain.ListGiftConfigsQuery { query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Keyword = strings.TrimSpace(query.Keyword) // 列表查询的 gift_type_code 是可选过滤条件;空值必须保持为空,避免礼物面板加载全部礼物时被默认收窄到 normal。 if strings.TrimSpace(query.GiftTypeCode) != "" { query.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(query.GiftTypeCode) } query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) return query } func normalizeGiftTypeConfigListQuery(query resourcedomain.ListGiftTypeConfigsQuery) resourcedomain.ListGiftTypeConfigsQuery { query.Status = strings.ToLower(strings.TrimSpace(query.Status)) if query.Status != "" && !resourcedomain.ValidStatus(query.Status) { query.Status = "__invalid__" } return query } func normalizeResourceGrantsQuery(query resourcedomain.ListResourceGrantsQuery) resourcedomain.ListResourceGrantsQuery { query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) return query } func normalizeResourceShopItemsQuery(query resourcedomain.ListResourceShopItemsQuery) resourcedomain.ListResourceShopItemsQuery { query.ResourceType = resourcedomain.NormalizeResourceType(query.ResourceType) if query.ResourceType != "" && !resourcedomain.ResourceTypeSellableInShop(query.ResourceType) { query.ResourceType = "__invalid__" } query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Keyword = strings.TrimSpace(query.Keyword) query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) return query } func normalizePage(page int32, pageSize int32) (int32, int32) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } return page, pageSize } func resourceOffset(page int32, pageSize int32) int32 { return (page - 1) * pageSize } func placeholders(count int) string { if count <= 0 { return "" } items := make([]string, count) for index := range items { items[index] = "?" } return strings.Join(items, ",") } func int64AnyArgs(values []int64) []any { args := make([]any, 0, len(values)) for _, value := range values { args = append(args, value) } return args } func stringAnyArgs(values []string) []any { args := make([]any, 0, len(values)) for _, value := range values { args = append(args, value) } return args } func compactPositiveInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } return out } func normalizeEquipableResourceTypes(values []string) ([]string, error) { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { resourceType := resourcedomain.NormalizeResourceType(value) if resourceType == "" { // 空类型不限制查询范围,保持 batch API 可以表达“查全部可佩戴资源”。 continue } if !resourcedomain.ValidResourceType(resourceType) || !isEquipableResourceType(resourceType) { return nil, xerr.New(xerr.InvalidArgument, "resource_type cannot be equipped") } if _, exists := seen[resourceType]; exists { // 去重后拼 IN (...),避免无意义扩大 SQL 参数和破坏调用方顺序稳定性。 continue } seen[resourceType] = struct{}{} out = append(out, resourceType) } return out, nil } func (r *Repository) pruneExpiredUserResourceEquipment(ctx context.Context, userIDs []int64, resourceTypes []string, nowMs int64) error { userIDs = compactPositiveInt64s(userIDs) if r == nil || r.db == nil || len(userIDs) == 0 { return nil } // 清理函数和查询函数复用同一套类型校验,避免查询拒绝的类型被清理 SQL 接受。 resourceTypes, err := normalizeEquipableResourceTypes(resourceTypes) if err != nil { return err } args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(userIDs)...) args = append(args, nowMs, nowMs) typeFilter := "" if len(resourceTypes) > 0 { typeFilter = ` AND eq.resource_type IN (` + placeholders(len(resourceTypes)) + `)` args = append(args, stringAnyArgs(resourceTypes)...) } // 删除条件覆盖三类 stale 指针: // 1. entitlement/resource 被删或不可用; // 2. entitlement 未生效、过期、剩余次数耗尽; // 3. resource 被下架。查询端随后只会看到仍可展示的佩戴。 _, err = r.db.ExecContext(ctx, ` DELETE eq FROM user_resource_equipment eq LEFT JOIN user_resource_entitlements e ON e.app_code = eq.app_code AND e.user_id = eq.user_id AND e.resource_id = eq.resource_id AND e.entitlement_id = eq.entitlement_id LEFT JOIN resources r ON r.app_code = eq.app_code AND r.resource_id = eq.resource_id WHERE eq.app_code = ? AND eq.user_id IN (`+placeholders(len(userIDs))+`) AND ( e.entitlement_id IS NULL OR e.status <> 'active' OR e.effective_at_ms > ? OR (e.expires_at_ms <> 0 AND e.expires_at_ms <= ?) OR e.remaining_quantity <= 0 OR r.resource_id IS NULL OR r.status <> 'active' )`+typeFilter, args..., ) return err } func mapResourceWriteError(err error) error { if isMySQLDuplicateError(err) { return xerr.New(xerr.Conflict, "resource_code already exists") } return err } func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand { command.ResourceCode = strings.TrimSpace(command.ResourceCode) command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType) command.Name = strings.TrimSpace(command.Name) command.Status = resourcedomain.NormalizeStatus(command.Status) command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy) command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType) command.PriceType = resourcedomain.NormalizePriceType(command.PriceType) if command.PriceType == resourcedomain.PriceTypeFree { command.CoinPrice = 0 command.GiftPointAmount = 0 } else if command.PriceType == resourcedomain.PriceTypeCoin { // 资源价格只保留真实 COIN 价格;gift_point_amount 是历史字段,写入 0 兼容旧表结构。 command.GiftPointAmount = 0 } command.AssetURL = strings.TrimSpace(command.AssetURL) command.PreviewURL = strings.TrimSpace(command.PreviewURL) command.AnimationURL = strings.TrimSpace(command.AnimationURL) command.MetadataJSON = normalizeJSONObject(command.MetadataJSON) command.UsageScopes = normalizeStringList(command.UsageScopes) return command } func validateResourceCommand(command resourcedomain.ResourceCommand) error { if command.ResourceCode == "" || command.Name == "" || command.OperatorUserID < 0 { return xerr.New(xerr.InvalidArgument, "resource command is incomplete") } if !resourcedomain.ValidResourceType(command.ResourceType) { return xerr.New(xerr.InvalidArgument, "resource_type is invalid") } if !resourcedomain.ValidStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } if !resourcedomain.ValidGrantStrategy(command.GrantStrategy) { return xerr.New(xerr.InvalidArgument, "grant_strategy is invalid") } if command.ResourceType == resourcedomain.TypeCoin { if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 { return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN") } } else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 { return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit") } if command.PriceType == "" { if command.CoinPrice != 0 || command.GiftPointAmount != 0 { return xerr.New(xerr.InvalidArgument, "resource price type is required") } return nil } if !resourcedomain.ValidPriceType(command.PriceType) { return xerr.New(xerr.InvalidArgument, "resource price type is invalid") } if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 { return xerr.New(xerr.InvalidArgument, "resource coin price is invalid") } if command.CoinPrice < 0 { return xerr.New(xerr.InvalidArgument, "resource price is invalid") } return nil } func normalizeResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) resourcedomain.ResourceShopItemsCommand { for index := range command.Items { command.Items[index].Status = resourcedomain.NormalizeStatus(command.Items[index].Status) } return command } func validateResourceShopItemsCommand(command resourcedomain.ResourceShopItemsCommand) error { if len(command.Items) == 0 || command.OperatorUserID < 0 { return xerr.New(xerr.InvalidArgument, "resource shop command is incomplete") } for _, item := range command.Items { if item.ResourceID <= 0 { return xerr.New(xerr.InvalidArgument, "resource_id is required") } if !resourcedomain.ValidStatus(item.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } if !resourcedomain.ValidShopDurationDays(item.DurationDays) { return xerr.New(xerr.InvalidArgument, "resource shop duration is invalid") } if item.EffectiveFromMS < 0 || item.EffectiveToMS < 0 || (item.EffectiveFromMS > 0 && item.EffectiveToMS > 0 && item.EffectiveToMS <= item.EffectiveFromMS) { return xerr.New(xerr.InvalidArgument, "resource shop effective time is invalid") } } return nil } func validateResourceShopResource(resource resourcedomain.Resource) error { if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) { return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop") } if err := validateGrantableResource(resource); err != nil { return err } if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 { return xerr.New(xerr.InvalidArgument, "resource coin price is required") } return nil } func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error { if item.Status != resourcedomain.StatusActive { return xerr.New(xerr.Conflict, "resource shop item is disabled") } if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs { return xerr.New(xerr.Conflict, "resource shop item is not effective") } if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs { return xerr.New(xerr.Conflict, "resource shop item is expired") } if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 { return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid") } return validateResourceShopResource(item.Resource) } func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand { command.GroupCode = strings.TrimSpace(command.GroupCode) command.Name = strings.TrimSpace(command.Name) command.Status = resourcedomain.NormalizeStatus(command.Status) command.Description = strings.TrimSpace(command.Description) for index := range command.Items { command.Items[index] = normalizeResourceGroupItemInput(command.Items[index]) } return command } func validateResourceGroupCommand(command resourcedomain.ResourceGroupCommand, requireItems bool) error { if command.GroupCode == "" || command.Name == "" { return xerr.New(xerr.InvalidArgument, "resource group command is incomplete") } if !resourcedomain.ValidStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } if requireItems && len(command.Items) == 0 { return xerr.New(xerr.InvalidArgument, "resource group has no items") } seenResources := make(map[int64]struct{}, len(command.Items)) seenWalletAssets := make(map[string]struct{}, len(command.Items)) for _, item := range command.Items { if !resourcedomain.ValidGroupItemType(item.ItemType) { return xerr.New(xerr.InvalidArgument, "resource group item type is invalid") } switch item.ItemType { case resourcedomain.GroupItemTypeResource: if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 { return xerr.New(xerr.InvalidArgument, "resource group item is invalid") } if _, ok := seenResources[item.ResourceID]; ok { return xerr.New(xerr.InvalidArgument, "resource group contains duplicate resource") } seenResources[item.ResourceID] = struct{}{} case resourcedomain.GroupItemTypeWalletAsset: if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 { return xerr.New(xerr.InvalidArgument, "resource group wallet asset item is invalid") } if _, ok := seenWalletAssets[item.WalletAssetType]; ok { return xerr.New(xerr.InvalidArgument, "resource group contains duplicate wallet asset") } seenWalletAssets[item.WalletAssetType] = struct{}{} } } return nil } func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput) resourcedomain.ResourceGroupItemInput { item.ItemType = resourcedomain.NormalizeGroupItemType(item.ItemType) item.WalletAssetType = resourcedomain.NormalizeWalletAssetType(item.WalletAssetType) if item.ItemType == resourcedomain.GroupItemTypeWalletAsset { item.ResourceID = 0 item.Quantity = 1 item.DurationMS = 0 return item } item.ItemType = resourcedomain.GroupItemTypeResource item.WalletAssetType = "" item.WalletAssetAmount = 0 return item } func validGroupWalletAssetType(assetType string) bool { switch resourcedomain.NormalizeWalletAssetType(assetType) { case ledger.AssetCoin: return true default: return false } } func applyResourcePricingToGiftCommand(command resourcedomain.GiftConfigCommand, resource resourcedomain.Resource) resourcedomain.GiftConfigCommand { switch resourcedomain.NormalizePriceType(resource.PriceType) { case resourcedomain.PriceTypeCoin: command.ChargeAssetType = ledger.AssetCoin command.CoinPrice = resource.CoinPrice command.GiftPointAmount = 0 case resourcedomain.PriceTypeFree: command.ChargeAssetType = ledger.AssetCoin command.CoinPrice = 0 command.GiftPointAmount = 0 } // 后台已取消热度配置;旧列只保留兼容写入,数值始终跟真实 COIN 价格一致。 command.HeatValue = command.CoinPrice return command } func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand { command.GiftID = strings.TrimSpace(command.GiftID) command.Status = resourcedomain.NormalizeStatus(command.Status) command.Name = strings.TrimSpace(command.Name) command.PresentationJSON = normalizeJSONObject(command.PresentationJSON) command.CPRelationType = normalizeCPRelationType(command.CPRelationType) command.PriceVersion = strings.TrimSpace(command.PriceVersion) command.RegionIDs = normalizeRegionIDs(command.RegionIDs) command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode) command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType)) if command.ChargeAssetType == "" { command.ChargeAssetType = ledger.AssetCoin } command.EffectTypes = normalizeGiftEffectTypes(command.EffectTypes) // gift_point_amount 是历史字段,礼物配置保存时固定归零,送礼结算只读取真实 COIN 价格。 command.GiftPointAmount = 0 if command.EffectiveAtMS < 0 { command.EffectiveAtMS = 0 } if command.EffectiveFromMS < 0 { command.EffectiveFromMS = 0 } if command.EffectiveToMS < 0 { command.EffectiveToMS = 0 } return command } func normalizeGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) resourcedomain.GiftTypeConfigCommand { command.TypeCode = resourcedomain.NormalizeGiftTypeCode(command.TypeCode) command.Name = strings.TrimSpace(command.Name) command.TabKey = resourcedomain.NormalizeGiftTypeTabKey(command.TabKey) command.Status = resourcedomain.NormalizeStatus(command.Status) return command } func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error { if command.GiftID == "" || command.ResourceID <= 0 || command.Name == "" || command.PriceVersion == "" { return xerr.New(xerr.InvalidArgument, "gift config command is incomplete") } if !resourcedomain.ValidStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } if command.CoinPrice < 0 || command.HeatValue < 0 { return xerr.New(xerr.InvalidArgument, "gift price is invalid") } if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) { return xerr.New(xerr.InvalidArgument, "gift type is invalid") } if command.CPRelationType != "" && command.GiftTypeCode != resourcedomain.GiftTypeCP { return xerr.New(xerr.InvalidArgument, "cp relation type requires cp gift type") } if !ledger.ValidGiftChargeAssetType(command.ChargeAssetType) { return xerr.New(xerr.InvalidArgument, "gift charge asset type is invalid") } if command.EffectiveFromMS > 0 && command.EffectiveToMS > 0 && command.EffectiveToMS <= command.EffectiveFromMS { return xerr.New(xerr.InvalidArgument, "gift effective time range is invalid") } for _, effectType := range command.EffectTypes { if !resourcedomain.ValidGiftEffectType(effectType) { return xerr.New(xerr.InvalidArgument, "gift effect type is invalid") } } if len(command.RegionIDs) == 0 { return xerr.New(xerr.InvalidArgument, "gift regions are required") } for _, regionID := range command.RegionIDs { if regionID < 0 { return xerr.New(xerr.InvalidArgument, "gift region is invalid") } } return nil } func validateGiftTypeConfigCommand(command resourcedomain.GiftTypeConfigCommand) error { if !resourcedomain.ValidGiftTypeCode(command.TypeCode) || command.Name == "" || !resourcedomain.ValidGiftTypeTabKey(command.TabKey) { return xerr.New(xerr.InvalidArgument, "gift type config is incomplete") } if !resourcedomain.ValidStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } return nil } func normalizeGrantResourceCommand(command resourcedomain.GrantResourceCommand) resourcedomain.GrantResourceCommand { command.CommandID = strings.TrimSpace(command.CommandID) command.Reason = strings.TrimSpace(command.Reason) command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) if command.Quantity == 0 { command.Quantity = 1 } return command } func validateGrantResourceCommand(command resourcedomain.GrantResourceCommand) error { if command.CommandID == "" || command.TargetUserID <= 0 || command.ResourceID <= 0 || command.Quantity <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { return xerr.New(xerr.InvalidArgument, "resource grant command is incomplete") } if command.DurationMS < 0 { return xerr.New(xerr.InvalidArgument, "duration_ms is invalid") } return nil } func normalizeGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) resourcedomain.GrantResourceGroupCommand { command.CommandID = strings.TrimSpace(command.CommandID) command.Reason = strings.TrimSpace(command.Reason) command.GrantSource = resourcedomain.NormalizeGrantSource(command.GrantSource) return command } func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroupCommand) error { if command.CommandID == "" || command.TargetUserID <= 0 || command.GroupID <= 0 || command.OperatorUserID <= 0 || command.Reason == "" { return xerr.New(xerr.InvalidArgument, "resource group grant command is incomplete") } if command.GrantSource == resourcedomain.GrantSourceManagerCenter { return xerr.New(xerr.InvalidArgument, "manager center cannot grant resource group") } return nil } func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error { if err := validateGrantableResource(resource); err != nil { return err } if grantSource == resourcedomain.GrantSourceManagerCenter && !resource.ManagerGrantEnabled { return xerr.New(xerr.PermissionDenied, "resource is not enabled for manager center grant") } return nil } func validateGrantableResource(resource resourcedomain.Resource) error { if resource.Status != resourcedomain.StatusActive { return xerr.New(xerr.Conflict, "resource is disabled") } if !resource.Grantable { return xerr.New(xerr.Conflict, "resource is not grantable") } if resourcedomain.IsWalletCredit(resource) && !resourcedomain.IsCoinResource(resource) { return xerr.New(xerr.InvalidArgument, "wallet credit resource must be coin") } return nil } func validateActiveResourceGroupItems(items []resourcedomain.ResourceGroupItem) error { if len(items) == 0 { return xerr.New(xerr.InvalidArgument, "resource group has no items") } for _, item := range items { if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource { continue } if err := validateActiveResourceGroupResource(item.Resource); err != nil { return err } } return nil } func validateActiveResourceGroupResource(resource resourcedomain.Resource) error { if err := validateGrantableResource(resource); err != nil { return err } if resourcedomain.IsCoinResource(resource) { return xerr.New(xerr.InvalidArgument, "coin resource must use wallet asset item") } return nil } func isEquipableResourceType(resourceType string) bool { switch resourcedomain.NormalizeResourceType(resourceType) { case resourcedomain.TypeAvatarFrame, resourcedomain.TypeProfileCard, resourcedomain.TypeVehicle, resourcedomain.TypeChatBubble, resourcedomain.TypeBadge: return true default: return false } } func resourceJSONPayload(scopes []string, metadata string) (string, string, error) { scopes = normalizeStringList(scopes) scopeJSON, err := json.Marshal(scopes) if err != nil { return "", "", err } return string(scopeJSON), normalizeJSONObject(metadata), nil } func normalizeStringList(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func normalizeRegionIDs(values []int64) []int64 { if len(values) == 0 { return []int64{0} } seen := make(map[int64]struct{}, len(values)) out := make([]int64, 0, len(values)) for _, value := range values { if value < 0 { out = append(out, value) continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } slices.Sort(out) return out } func normalizeGiftEffectTypes(values []string) []string { seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = resourcedomain.NormalizeGiftEffectType(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } sort.Strings(out) return out } func parseStringArray(value string) []string { var out []string if err := json.Unmarshal([]byte(value), &out); err != nil { return nil } return normalizeStringList(out) } func normalizeJSONObject(value string) string { value = strings.TrimSpace(value) if value == "" { return "{}" } var raw any if err := json.Unmarshal([]byte(value), &raw); err != nil { return "{}" } if _, ok := raw.(map[string]any); !ok { return "{}" } return value } func cpRelationTypeFromPresentationJSON(value string) string { var payload map[string]any if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil { return "" } if raw, ok := payload["cp_relation_type"].(string); ok { return normalizeCPRelationType(raw) } return "" } func giftPresentationWithCPRelationType(value string, relationType string) string { relationType = normalizeCPRelationType(relationType) var payload map[string]any if err := json.Unmarshal([]byte(normalizeJSONObject(value)), &payload); err != nil || payload == nil { payload = map[string]any{} } // 空值表示清除 CP 关系类型;非 CP 礼物保存时不能残留旧的 cp_relation_type。 if relationType == "" { delete(payload, "cp_relation_type") } else { payload["cp_relation_type"] = relationType } encoded, err := json.Marshal(payload) if err != nil { return "{}" } return string(encoded) } func normalizeCPRelationType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "cp": return "cp" case "brother": return "brother" case "sister": return "sister" default: return "" } } func resourceOutboxEvent(eventType string, commandID string, userID int64, resourceID int64, payload any, nowMs int64) walletOutboxEvent { eventKey := fmt.Sprintf("%s|%s|%d|%d", eventType, commandID, userID, resourceID) return walletOutboxEvent{ EventID: "wev_" + stableHash(eventKey), EventType: eventType, TransactionID: "", CommandID: commandID, UserID: userID, AssetType: resourceOutboxAsset, Payload: payload, CreatedAtMS: nowMs, } } func (r *Repository) insertResourceOutboxNoTx(ctx context.Context, eventType string, commandID string, userID int64, resourceID int64, payload any) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() nowMs := time.Now().UnixMilli() if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{resourceOutboxEvent(eventType, commandID, userID, resourceID, payload, nowMs)}); err != nil { return err } return tx.Commit() } func grantResourceRequestHash(command resourcedomain.GrantResourceCommand) string { return stableHash(fmt.Sprintf("resource_grant|%s|%d|%d|%d|%d|%s|%d|%s", appcode.Normalize(command.AppCode), command.TargetUserID, command.ResourceID, command.Quantity, command.DurationMS, command.Reason, command.OperatorUserID, command.GrantSource, )) } func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupCommand) string { return stableHash(fmt.Sprintf("resource_group_grant|%s|%d|%d|%s|%d|%s", appcode.Normalize(command.AppCode), command.TargetUserID, command.GroupID, command.Reason, command.OperatorUserID, command.GrantSource, )) } func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string { return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d", appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID, )) } func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string { return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity)) } func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType string, amount int64) string { return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount)) } func resourceGrantID(appCode string, commandID string) string { return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) } func entitlementID(appCode string, grantID string, resourceID int64, nowMs int64) string { return "ent_" + stableHash(fmt.Sprintf("%s|%s|%d|%d", appcode.Normalize(appCode), grantID, resourceID, nowMs)) }