vip问题,以及每日任务相关

This commit is contained in:
zhx 2026-07-23 17:54:44 +08:00
parent 9ef8df22f0
commit 1069d95e9b
11 changed files with 487 additions and 71 deletions

View File

@ -228,6 +228,8 @@ CREATE TABLE IF NOT EXISTS task_definitions (
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型wallet_asset/resource/resource_group', reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型wallet_asset/resource/resource_group',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID', reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID', reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID',
reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量', reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期毫秒0 为永久', reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期毫秒0 为永久',
reward_items_json JSON NULL COMMENT '钱包资源目录解析出的奖励展示快照', reward_items_json JSON NULL COMMENT '钱包资源目录解析出的奖励展示快照',
@ -271,6 +273,8 @@ CREATE TABLE IF NOT EXISTS user_task_progress (
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照', reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照', reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照', reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照', reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照', reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
reward_items_json JSON NULL COMMENT '奖励展示快照', reward_items_json JSON NULL COMMENT '奖励展示快照',
@ -315,6 +319,8 @@ CREATE TABLE IF NOT EXISTS task_reward_claims (
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照', reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照', reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照', reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照', reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照', reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
reward_items_json JSON NULL COMMENT '奖励展示快照', reward_items_json JSON NULL COMMENT '奖励展示快照',

View File

@ -0,0 +1,18 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 资源组任务必须保存 wallet owner 的不可变快照引用;这里只在三张任务热表尾部追加定长字段。
-- MySQL 8 的 INSTANT 变更不复制数据页,只需要获取短暂 metadata lock避免随任务历史量线性放大迁移耗时。
ALTER TABLE task_definitions
ADD COLUMN reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
ADD COLUMN reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
ALGORITHM=INSTANT;
ALTER TABLE user_task_progress
ADD COLUMN reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
ADD COLUMN reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
ALGORITHM=INSTANT;
ALTER TABLE task_reward_claims
ADD COLUMN reward_snapshot_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照 ID',
ADD COLUMN reward_snapshot_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '资源组 owner 快照内容哈希',
ALGORITHM=INSTANT;

View File

@ -99,6 +99,8 @@ type Definition struct {
RewardAssetType string RewardAssetType string
RewardResourceID int64 RewardResourceID int64
RewardResourceGroupID int64 RewardResourceGroupID int64
RewardSnapshotID string
RewardSnapshotHash string
RewardQuantity int64 RewardQuantity int64
RewardDurationMS int64 RewardDurationMS int64
RewardItemsJSON string RewardItemsJSON string
@ -128,6 +130,8 @@ type Progress struct {
RewardAssetType string RewardAssetType string
RewardResourceID int64 RewardResourceID int64
RewardResourceGroupID int64 RewardResourceGroupID int64
RewardSnapshotID string
RewardSnapshotHash string
RewardQuantity int64 RewardQuantity int64
RewardDurationMS int64 RewardDurationMS int64
RewardItemsJSON string RewardItemsJSON string
@ -195,6 +199,8 @@ type DefinitionCommand struct {
RewardAssetType string RewardAssetType string
RewardResourceID int64 RewardResourceID int64
RewardResourceGroupID int64 RewardResourceGroupID int64
RewardSnapshotID string
RewardSnapshotHash string
RewardQuantity int64 RewardQuantity int64
RewardDurationMS int64 RewardDurationMS int64
RewardItemsJSON string RewardItemsJSON string
@ -246,6 +252,8 @@ type Claim struct {
RewardAssetType string RewardAssetType string
RewardResourceID int64 RewardResourceID int64
RewardResourceGroupID int64 RewardResourceGroupID int64
RewardSnapshotID string
RewardSnapshotHash string
RewardQuantity int64 RewardQuantity int64
RewardDurationMS int64 RewardDurationMS int64
RewardItemsJSON string RewardItemsJSON string

View File

@ -2,8 +2,11 @@ package task
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@ -39,8 +42,10 @@ type WalletClient interface {
type ResourceWalletClient interface { type ResourceWalletClient interface {
GetResource(ctx context.Context, req *walletv1.GetResourceRequest, opts ...grpc.CallOption) (*walletv1.GetResourceResponse, error) GetResource(ctx context.Context, req *walletv1.GetResourceRequest, opts ...grpc.CallOption) (*walletv1.GetResourceResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error)
PinResourceGroupSnapshot(ctx context.Context, req *walletv1.PinResourceGroupSnapshotRequest, opts ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantPinnedResourceGroup(ctx context.Context, req *walletv1.GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
} }
// Service 承载任务查询、事件消费、领奖和后台配置用例。 // Service 承载任务查询、事件消费、领奖和后台配置用例。
@ -208,14 +213,34 @@ func (s *Service) grantClaimReward(ctx context.Context, claim taskdomain.Claim,
if s.resourceWallet == nil { if s.resourceWallet == nil {
return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured") return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
} }
resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{ snapshotID := strings.TrimSpace(claim.RewardSnapshotID)
CommandId: claim.WalletCommandID, snapshotHash := strings.ToLower(strings.TrimSpace(claim.RewardSnapshotHash))
AppCode: appcode.FromContext(ctx), if snapshotID == "" && snapshotHash == "" {
TargetUserId: claim.UserID, // 兼容 016 上线前已产生进度的短暂历史数据:旧记录没有 owner 快照引用,只能按当时的 group_id 走原有赠送。
GroupId: claim.RewardResourceGroupID, // 新配置一律在保存时 pin迁移默认空值不能伪造快照该分支可在确认旧进度清空后再下线。
Reason: reason, resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
OperatorUserId: claim.UserID, CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID,
GrantSource: "daily_task", GroupId: claim.RewardResourceGroupID, Reason: reason, OperatorUserId: claim.UserID, GrantSource: "daily_task",
})
if err != nil {
return "", "", 0, err
}
return resourceGrantReceipt(resp)
}
if snapshotID == "" || !validRewardSnapshotHash(snapshotHash) {
return "", "", 0, xerr.New(xerr.Conflict, "task resource group snapshot is missing")
}
// 资源组可能在任务完成后被运营编辑;领取必须使用进度中固化的 owner 快照,
// 不能回读当前 group_id否则 H5 已展示的奖励与实际到账资源会发生漂移。
resp, err := s.resourceWallet.GrantPinnedResourceGroup(ctx, &walletv1.GrantPinnedResourceGroupRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
SnapshotId: snapshotID,
Reason: reason,
OperatorUserId: claim.UserID,
GrantSource: "daily_task",
ExpectedSnapshotHash: snapshotHash,
}) })
if err != nil { if err != nil {
return "", "", 0, err return "", "", 0, err
@ -428,6 +453,8 @@ func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdom
def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType) def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType)
def.RewardResourceID = progress.RewardResourceID def.RewardResourceID = progress.RewardResourceID
def.RewardResourceGroupID = progress.RewardResourceGroupID def.RewardResourceGroupID = progress.RewardResourceGroupID
def.RewardSnapshotID = progress.RewardSnapshotID
def.RewardSnapshotHash = progress.RewardSnapshotHash
def.RewardQuantity = progress.RewardQuantity def.RewardQuantity = progress.RewardQuantity
def.RewardDurationMS = progress.RewardDurationMS def.RewardDurationMS = progress.RewardDurationMS
def.RewardItemsJSON = progress.RewardItemsJSON def.RewardItemsJSON = progress.RewardItemsJSON
@ -621,6 +648,8 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
// 非当前奖励类型字段必须清空,避免任务从资源切回金币后旧 ID 继续出现在 Admin/App 响应中。 // 非当前奖励类型字段必须清空,避免任务从资源切回金币后旧 ID 继续出现在 Admin/App 响应中。
command.RewardResourceID = 0 command.RewardResourceID = 0
command.RewardResourceGroupID = 0 command.RewardResourceGroupID = 0
command.RewardSnapshotID = ""
command.RewardSnapshotHash = ""
command.RewardQuantity = 0 command.RewardQuantity = 0
command.RewardDurationMS = 0 command.RewardDurationMS = 0
return withRewardItems(command, []taskdomain.RewardItem{{ return withRewardItems(command, []taskdomain.RewardItem{{
@ -647,6 +676,8 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
command.RewardCoinAmount = 0 command.RewardCoinAmount = 0
command.RewardAssetType = "" command.RewardAssetType = ""
command.RewardResourceGroupID = 0 command.RewardResourceGroupID = 0
command.RewardSnapshotID = ""
command.RewardSnapshotHash = ""
return withRewardItems(command, []taskdomain.RewardItem{item}) return withRewardItems(command, []taskdomain.RewardItem{item})
case taskdomain.RewardTypeResourceGroup: case taskdomain.RewardTypeResourceGroup:
if s.resourceWallet == nil { if s.resourceWallet == nil {
@ -661,14 +692,43 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
return taskdomain.DefinitionCommand{}, err return taskdomain.DefinitionCommand{}, err
} }
group := resp.GetGroup() group := resp.GetGroup()
if group == nil || group.GetGroupId() != command.RewardResourceGroupID || strings.ToLower(strings.TrimSpace(group.GetStatus())) != "active" { sourceContentHash := strings.ToLower(strings.TrimSpace(resp.GetSourceContentHash()))
if group == nil || group.GetGroupId() != command.RewardResourceGroupID ||
strings.ToLower(strings.TrimSpace(group.GetStatus())) != "active" ||
group.GetUpdatedAtMs() <= 0 || !validRewardSnapshotHash(sourceContentHash) {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.Conflict, "reward resource group is unavailable") return taskdomain.DefinitionCommand{}, xerr.New(xerr.Conflict, "reward resource group is unavailable")
} }
if len(group.GetItems()) == 0 { if len(group.GetItems()) == 0 {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group has no items") return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group has no items")
} }
items := make([]taskdomain.RewardItem, 0, len(group.GetItems())) pinKey := rewardSnapshotPinKey(ctx, command.RewardResourceGroupID, sourceContentHash)
for _, groupItem := range group.GetItems() { pinned, err := s.resourceWallet.PinResourceGroupSnapshot(ctx, &walletv1.PinResourceGroupSnapshotRequest{
RequestId: pinKey,
AppCode: appcode.FromContext(ctx),
GroupId: command.RewardResourceGroupID,
PinKey: pinKey,
OperatorUserId: command.OperatorAdminID,
ExpectedGroupUpdatedAtMs: group.GetUpdatedAtMs(),
ExpectedSourceContentHash: sourceContentHash,
// 每日任务没有区域定向字段,配置必须保证该快照对 App 全区域有效。
RequiredAllRegions: true,
})
if err != nil {
return taskdomain.DefinitionCommand{}, err
}
snapshot := pinned.GetSnapshot()
snapshotGroup := snapshot.GetGroup()
snapshotHash := strings.ToLower(strings.TrimSpace(snapshot.GetSnapshotHash()))
snapshotSourceHash := strings.ToLower(strings.TrimSpace(snapshot.GetSourceContentHash()))
if snapshot == nil || snapshotGroup == nil || snapshot.GetSnapshotId() == "" ||
snapshot.GetSourceGroupId() != command.RewardResourceGroupID ||
snapshot.GetSourceGroupUpdatedAtMs() != group.GetUpdatedAtMs() ||
snapshotSourceHash != sourceContentHash || snapshotHash != sourceContentHash ||
len(snapshotGroup.GetItems()) == 0 {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.Internal, "wallet returned an incomplete task reward snapshot")
}
items := make([]taskdomain.RewardItem, 0, len(snapshotGroup.GetItems()))
for _, groupItem := range snapshotGroup.GetItems() {
if strings.ToLower(strings.TrimSpace(groupItem.GetItemType())) == taskdomain.RewardItemTypeWalletAsset { if strings.ToLower(strings.TrimSpace(groupItem.GetItemType())) == taskdomain.RewardItemTypeWalletAsset {
if groupItem.GetWalletAssetType() == "" || groupItem.GetWalletAssetAmount() <= 0 { if groupItem.GetWalletAssetType() == "" || groupItem.GetWalletAssetAmount() <= 0 {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group contains an invalid wallet asset") return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group contains an invalid wallet asset")
@ -689,6 +749,8 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
command.RewardCoinAmount = 0 command.RewardCoinAmount = 0
command.RewardAssetType = "" command.RewardAssetType = ""
command.RewardResourceID = 0 command.RewardResourceID = 0
command.RewardSnapshotID = snapshot.GetSnapshotId()
command.RewardSnapshotHash = snapshotHash
command.RewardQuantity = 0 command.RewardQuantity = 0
command.RewardDurationMS = 0 command.RewardDurationMS = 0
return withRewardItems(command, items) return withRewardItems(command, items)
@ -739,6 +801,21 @@ func rewardCatalogRequestID(command taskdomain.DefinitionCommand) string {
return "task-reward-config-" + taskID return "task-reward-config-" + taskID
} }
func rewardSnapshotPinKey(ctx context.Context, groupID int64, sourceContentHash string) string {
// 同一 App、同一资源组版本可安全复用相同 owner 快照;哈希后的稳定 key 也让 Admin 重试不会生成重复快照。
identity := appcode.FromContext(ctx) + "\x00" + strconv.FormatInt(groupID, 10) + "\x00" + sourceContentHash
sum := sha256.Sum256([]byte(identity))
return "daily-task-reward-pin:" + hex.EncodeToString(sum[:])
}
func validRewardSnapshotHash(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) { func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
// 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。 // 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
payload := map[string]any{ payload := map[string]any{

View File

@ -410,8 +410,9 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &fakeWalletClient{ wallet := &fakeWalletClient{
group: &walletv1.ResourceGroup{ group: &walletv1.ResourceGroup{
GroupId: 9901, GroupId: 9901,
Status: "active", Status: "active",
UpdatedAtMs: 1778291000000,
Items: []*walletv1.ResourceGroupItem{ Items: []*walletv1.ResourceGroupItem{
{ {
ItemType: "resource", ItemType: "resource",
@ -471,7 +472,9 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
if len(rewardItems) != 3 || if len(rewardItems) != 3 ||
rewardItems[0].CoverURL != "https://cdn.example.com/rewards/frame.webp" || rewardItems[0].CoverURL != "https://cdn.example.com/rewards/frame.webp" ||
rewardItems[1].CoverURL != "https://cdn.example.com/rewards/badge.webp" || rewardItems[1].CoverURL != "https://cdn.example.com/rewards/badge.webp" ||
rewardItems[2].WalletAssetAmount != 50 { rewardItems[2].WalletAssetAmount != 50 ||
definition.RewardSnapshotID != "snapshot-task-group-1" ||
definition.RewardSnapshotHash != fakeTaskRewardSnapshotHash {
t.Fatalf("resource group display snapshot mismatch: %+v", rewardItems) t.Fatalf("resource group display snapshot mismatch: %+v", rewardItems)
} }
@ -496,9 +499,10 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("claim resource group task failed: %v", err) t.Fatalf("claim resource group task failed: %v", err)
} }
if claim.WalletResourceGrantID != "grant-task-1" || wallet.groupGrantCalls != 1 || if claim.WalletResourceGrantID != "grant-task-1" || wallet.pinnedGroupGrantCalls != 1 ||
wallet.lastGroupGrant.GetGroupId() != 9901 { wallet.lastPinnedGroupGrant.GetSnapshotId() != "snapshot-task-group-1" ||
t.Fatalf("resource group claim mismatch: claim=%+v wallet=%+v", claim, wallet.lastGroupGrant) wallet.lastPinnedGroupGrant.GetExpectedSnapshotHash() != fakeTaskRewardSnapshotHash {
t.Fatalf("resource group claim mismatch: claim=%+v wallet=%+v", claim, wallet.lastPinnedGroupGrant)
} }
} }
@ -671,16 +675,18 @@ func assertRecordedTaskEvents(t *testing.T, got []taskdomain.Event, want []expec
} }
type fakeWalletClient struct { type fakeWalletClient struct {
calls int calls int
last *walletv1.CreditTaskRewardRequest last *walletv1.CreditTaskRewardRequest
resource *walletv1.Resource resource *walletv1.Resource
group *walletv1.ResourceGroup group *walletv1.ResourceGroup
resourceGrantCalls int resourceGrantCalls int
groupGrantCalls int pinnedGroupGrantCalls int
lastResourceGrant *walletv1.GrantResourceRequest lastResourceGrant *walletv1.GrantResourceRequest
lastGroupGrant *walletv1.GrantResourceGroupRequest lastPinnedGroupGrant *walletv1.GrantPinnedResourceGroupRequest
} }
const fakeTaskRewardSnapshotHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) { func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
f.calls++ f.calls++
f.last = req f.last = req
@ -701,7 +707,18 @@ func (f *fakeWalletClient) GetResource(_ context.Context, _ *walletv1.GetResourc
} }
func (f *fakeWalletClient) GetResourceGroup(_ context.Context, _ *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) { func (f *fakeWalletClient) GetResourceGroup(_ context.Context, _ *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) {
return &walletv1.GetResourceGroupResponse{Group: f.group}, nil return &walletv1.GetResourceGroupResponse{Group: f.group, SourceContentHash: fakeTaskRewardSnapshotHash}, nil
}
func (f *fakeWalletClient) PinResourceGroupSnapshot(_ context.Context, _ *walletv1.PinResourceGroupSnapshotRequest, _ ...grpc.CallOption) (*walletv1.PinResourceGroupSnapshotResponse, error) {
return &walletv1.PinResourceGroupSnapshotResponse{Snapshot: &walletv1.ResourceGroupSnapshot{
SnapshotId: "snapshot-task-group-1",
SourceGroupId: f.group.GetGroupId(),
SourceGroupUpdatedAtMs: f.group.GetUpdatedAtMs(),
SourceContentHash: fakeTaskRewardSnapshotHash,
SnapshotHash: fakeTaskRewardSnapshotHash,
Group: f.group,
}}, nil
} }
func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
@ -714,9 +731,17 @@ func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantR
}}, nil }}, nil
} }
func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, _ *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.groupGrantCalls++ return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
f.lastGroupGrant = req GrantId: "grant-task-legacy-group-1",
CreatedAtMs: 1778292000000,
UpdatedAtMs: 1778292000000,
}}, nil
}
func (f *fakeWalletClient) GrantPinnedResourceGroup(_ context.Context, req *walletv1.GrantPinnedResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.pinnedGroupGrantCalls++
f.lastPinnedGroupGrant = req
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{ return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
GrantId: "grant-task-1", GrantId: "grant-task-1",
CreatedAtMs: 1778292000000, CreatedAtMs: 1778292000000,

View File

@ -27,7 +27,8 @@ func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([
COALESCE(CAST(action_payload_json AS CHAR), '{}'), COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version, COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
@ -62,7 +63,8 @@ func (r *Repository) ListUserProgress(ctx context.Context, userID int64, cycleKe
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value, SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, reward_type, target_value, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND cycle_key IN (`+placeholders+`)`, args...) WHERE app_code = ? AND user_id = ? AND cycle_key IN (`+placeholders+`)`, args...)
@ -176,7 +178,8 @@ func (r *Repository) ListTaskDefinitions(ctx context.Context, query taskdomain.D
COALESCE(CAST(action_payload_json AS CHAR), '{}'), COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version, COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
@ -240,6 +243,8 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
next.RewardAssetType = command.RewardAssetType next.RewardAssetType = command.RewardAssetType
next.RewardResourceID = command.RewardResourceID next.RewardResourceID = command.RewardResourceID
next.RewardResourceGroupID = command.RewardResourceGroupID next.RewardResourceGroupID = command.RewardResourceGroupID
next.RewardSnapshotID = command.RewardSnapshotID
next.RewardSnapshotHash = command.RewardSnapshotHash
next.RewardQuantity = command.RewardQuantity next.RewardQuantity = command.RewardQuantity
next.RewardDurationMS = command.RewardDurationMS next.RewardDurationMS = command.RewardDurationMS
next.RewardItemsJSON = command.RewardItemsJSON next.RewardItemsJSON = command.RewardItemsJSON
@ -263,7 +268,8 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
audience_type = ?, icon_key = ?, icon_url = ?, action_type = ?, action_param = ?, audience_type = ?, icon_key = ?, icon_url = ?, action_type = ?, action_param = ?,
action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON), action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON),
target_value = ?, target_unit = ?, reward_coin_amount = ?, reward_asset_type = ?, reward_type = ?, target_value = ?, target_unit = ?, reward_coin_amount = ?, reward_asset_type = ?, reward_type = ?,
reward_resource_id = ?, reward_resource_group_id = ?, reward_quantity = ?, reward_duration_ms = ?, reward_resource_id = ?, reward_resource_group_id = ?, reward_snapshot_id = ?, reward_snapshot_hash = ?,
reward_quantity = ?, reward_duration_ms = ?,
reward_items_json = CAST(? AS JSON), status = ?, sort_order = ?, reward_items_json = CAST(? AS JSON), status = ?, sort_order = ?,
version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_ms = ?, version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_ms = ?,
updated_by_admin_id = ?, updated_at_ms = ? updated_by_admin_id = ?, updated_at_ms = ?
@ -272,7 +278,8 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
next.AudienceType, next.IconKey, next.IconURL, next.ActionType, next.ActionParam, next.AudienceType, next.IconKey, next.IconURL, next.ActionType, next.ActionParam,
next.ActionPayloadJSON, next.DimensionFilterJSON, next.ActionPayloadJSON, next.DimensionFilterJSON,
next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.RewardAssetType, next.RewardType, next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.RewardAssetType, next.RewardType,
next.RewardResourceID, next.RewardResourceGroupID, next.RewardQuantity, next.RewardDurationMS, next.RewardResourceID, next.RewardResourceGroupID, next.RewardSnapshotID, next.RewardSnapshotHash,
next.RewardQuantity, next.RewardDurationMS,
next.RewardItemsJSON, next.Status, next.SortOrder, next.RewardItemsJSON, next.Status, next.SortOrder,
next.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS, next.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS,
next.UpdatedByAdminID, next.UpdatedAtMS, appcode.FromContext(ctx), next.TaskID, next.UpdatedByAdminID, next.UpdatedAtMS, appcode.FromContext(ctx), next.TaskID,
@ -379,6 +386,8 @@ func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.Cl
RewardAssetType: progress.RewardAssetType, RewardAssetType: progress.RewardAssetType,
RewardResourceID: progress.RewardResourceID, RewardResourceID: progress.RewardResourceID,
RewardResourceGroupID: progress.RewardResourceGroupID, RewardResourceGroupID: progress.RewardResourceGroupID,
RewardSnapshotID: progress.RewardSnapshotID,
RewardSnapshotHash: progress.RewardSnapshotHash,
RewardQuantity: progress.RewardQuantity, RewardQuantity: progress.RewardQuantity,
RewardDurationMS: progress.RewardDurationMS, RewardDurationMS: progress.RewardDurationMS,
RewardItemsJSON: progress.RewardItemsJSON, RewardItemsJSON: progress.RewardItemsJSON,
@ -391,12 +400,14 @@ func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.Cl
INSERT INTO task_reward_claims ( INSERT INTO task_reward_claims (
app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key, app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key,
reward_coin_amount, reward_asset_type, reward_type, reward_resource_id, reward_resource_group_id, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id, reward_resource_group_id,
reward_quantity, reward_duration_ms, reward_items_json, wallet_command_id, status, reward_snapshot_id, reward_snapshot_hash, reward_quantity, reward_duration_ms,
reward_items_json, wallet_command_id, status,
failure_reason, created_at_ms, updated_at_ms failure_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, '', ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, '', ?, ?)`,
appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType, appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType,
claim.CycleKey, claim.RewardCoinAmount, claim.RewardAssetType, claim.RewardType, claim.RewardResourceID, claim.CycleKey, claim.RewardCoinAmount, claim.RewardAssetType, claim.RewardType, claim.RewardResourceID,
claim.RewardResourceGroupID, claim.RewardQuantity, claim.RewardDurationMS, claim.RewardItemsJSON, claim.RewardResourceGroupID, claim.RewardSnapshotID, claim.RewardSnapshotHash,
claim.RewardQuantity, claim.RewardDurationMS, claim.RewardItemsJSON,
claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS, claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS,
); err != nil { ); err != nil {
return taskdomain.Claim{}, err return taskdomain.Claim{}, err
@ -503,7 +514,8 @@ func (r *Repository) listDefinitionsForEvent(ctx context.Context, tx *sql.Tx, me
COALESCE(CAST(action_payload_json AS CHAR), '{}'), COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version, COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
@ -539,12 +551,14 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
INSERT INTO user_task_progress ( INSERT INTO user_task_progress (
app_code, user_id, task_id, task_version_id, cycle_key, progress_value, app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id, target_value, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id,
reward_resource_group_id, reward_quantity, reward_duration_ms, reward_items_json, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms, reward_items_json,
status, completed_at_ms, claimed_at_ms, updated_at_ms status, completed_at_ms, claimed_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, 0, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, 0, ?)`,
appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta, appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta,
definition.TargetValue, definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType, definition.TargetValue, definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType,
definition.RewardResourceID, definition.RewardResourceGroupID, definition.RewardQuantity, definition.RewardResourceID, definition.RewardResourceGroupID,
definition.RewardSnapshotID, definition.RewardSnapshotHash, definition.RewardQuantity,
definition.RewardDurationMS, definition.RewardItemsJSON, status, completedAtMS, nowMS, definition.RewardDurationMS, definition.RewardItemsJSON, status, completedAtMS, nowMS,
) )
return err == nil, err return err == nil, err
@ -593,6 +607,8 @@ func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, comma
RewardAssetType: command.RewardAssetType, RewardAssetType: command.RewardAssetType,
RewardResourceID: command.RewardResourceID, RewardResourceID: command.RewardResourceID,
RewardResourceGroupID: command.RewardResourceGroupID, RewardResourceGroupID: command.RewardResourceGroupID,
RewardSnapshotID: command.RewardSnapshotID,
RewardSnapshotHash: command.RewardSnapshotHash,
RewardQuantity: command.RewardQuantity, RewardQuantity: command.RewardQuantity,
RewardDurationMS: command.RewardDurationMS, RewardDurationMS: command.RewardDurationMS,
RewardItemsJSON: command.RewardItemsJSON, RewardItemsJSON: command.RewardItemsJSON,
@ -616,17 +632,19 @@ func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, comma
app_code, task_id, task_type, category, metric_type, title, description, app_code, task_id, task_type, category, metric_type, title, description,
audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json, audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id,
reward_resource_group_id, reward_quantity, reward_duration_ms, reward_items_json, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms, reward_items_json,
status, sort_order, version, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType, definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType,
definition.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL, definition.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL,
definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON, definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON,
definition.TargetValue, definition.TargetUnit, definition.TargetValue, definition.TargetUnit,
definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType, definition.RewardResourceID, definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType, definition.RewardResourceID,
definition.RewardResourceGroupID, definition.RewardQuantity, definition.RewardDurationMS, definition.RewardItemsJSON, definition.RewardResourceGroupID, definition.RewardSnapshotID, definition.RewardSnapshotHash,
definition.RewardQuantity, definition.RewardDurationMS, definition.RewardItemsJSON,
definition.Status, definition.SortOrder, definition.Version, definition.Status, definition.SortOrder, definition.Version,
definition.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS, definition.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS,
definition.CreatedByAdminID, definition.UpdatedByAdminID, definition.CreatedAtMS, definition.UpdatedAtMS, definition.CreatedByAdminID, definition.UpdatedByAdminID, definition.CreatedAtMS, definition.UpdatedAtMS,
@ -662,7 +680,8 @@ func (r *Repository) getTaskDefinition(ctx context.Context, taskID string) (task
COALESCE(CAST(action_payload_json AS CHAR), '{}'), COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version, COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
@ -680,7 +699,8 @@ func (r *Repository) getTaskDefinitionForUpdate(ctx context.Context, tx *sql.Tx,
COALESCE(CAST(action_payload_json AS CHAR), '{}'), COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version, COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms updated_by_admin_id, created_at_ms, updated_at_ms
@ -700,7 +720,8 @@ func (r *Repository) getProgressForUpdate(ctx context.Context, tx *sql.Tx, userI
row := tx.QueryRowContext(ctx, ` row := tx.QueryRowContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value, SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, reward_type, target_value, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ? WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?
@ -786,6 +807,8 @@ func scanDefinition(row rowScanner) (taskdomain.Definition, error) {
&definition.RewardType, &definition.RewardType,
&definition.RewardResourceID, &definition.RewardResourceID,
&definition.RewardResourceGroupID, &definition.RewardResourceGroupID,
&definition.RewardSnapshotID,
&definition.RewardSnapshotHash,
&definition.RewardQuantity, &definition.RewardQuantity,
&definition.RewardDurationMS, &definition.RewardDurationMS,
&definition.RewardItemsJSON, &definition.RewardItemsJSON,
@ -818,6 +841,8 @@ func scanProgress(row rowScanner) (taskdomain.Progress, error) {
&progress.RewardType, &progress.RewardType,
&progress.RewardResourceID, &progress.RewardResourceID,
&progress.RewardResourceGroupID, &progress.RewardResourceGroupID,
&progress.RewardSnapshotID,
&progress.RewardSnapshotHash,
&progress.RewardQuantity, &progress.RewardQuantity,
&progress.RewardDurationMS, &progress.RewardDurationMS,
&progress.RewardItemsJSON, &progress.RewardItemsJSON,
@ -843,6 +868,8 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
&claim.RewardType, &claim.RewardType,
&claim.RewardResourceID, &claim.RewardResourceID,
&claim.RewardResourceGroupID, &claim.RewardResourceGroupID,
&claim.RewardSnapshotID,
&claim.RewardSnapshotHash,
&claim.RewardQuantity, &claim.RewardQuantity,
&claim.RewardDurationMS, &claim.RewardDurationMS,
&claim.RewardItemsJSON, &claim.RewardItemsJSON,
@ -859,7 +886,8 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
func taskClaimSelectSQL() string { func taskClaimSelectSQL() string {
return `SELECT claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount, reward_asset_type, return `SELECT claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount, reward_asset_type,
reward_type, reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms, reward_type, reward_resource_id, reward_resource_group_id, reward_snapshot_id, reward_snapshot_hash,
reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), wallet_command_id, wallet_transaction_id, COALESCE(CAST(reward_items_json AS CHAR), '[]'), wallet_command_id, wallet_transaction_id,
wallet_resource_grant_id, status, failure_reason, created_at_ms, updated_at_ms wallet_resource_grant_id, status, failure_reason, created_at_ms, updated_at_ms
FROM task_reward_claims ` FROM task_reward_claims `
@ -902,6 +930,8 @@ func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.D
current.RewardType != next.RewardType || current.RewardType != next.RewardType ||
current.RewardResourceID != next.RewardResourceID || current.RewardResourceID != next.RewardResourceID ||
current.RewardResourceGroupID != next.RewardResourceGroupID || current.RewardResourceGroupID != next.RewardResourceGroupID ||
current.RewardSnapshotID != next.RewardSnapshotID ||
current.RewardSnapshotHash != next.RewardSnapshotHash ||
current.RewardQuantity != next.RewardQuantity || current.RewardQuantity != next.RewardQuantity ||
current.RewardDurationMS != next.RewardDurationMS || current.RewardDurationMS != next.RewardDurationMS ||
current.RewardItemsJSON != next.RewardItemsJSON current.RewardItemsJSON != next.RewardItemsJSON

View File

@ -570,6 +570,150 @@ func TestFamiVIPPurchaseReplacesCoveredTrialCard(t *testing.T) {
} }
} }
// TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox 验证 VIP8 手动佩戴 VIP6
// 麦位皮肤后,两个读取入口都不会恢复 VIP8 默认值;槽位确实丢失时仍会懒恢复,
// 且相同 vip_state_sync 事实再次生成只重放既有 outbox 行,不向接口泄漏 1062。
func TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
ctx := appcode.WithContext(context.Background(), "fami")
createMicSkin := func(code string) resourcedomain.Resource {
t.Helper()
resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "fami",
ResourceCode: code,
ResourceType: resourcedomain.TypeMicSeatIcon,
Name: code,
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"mic_seat"},
AssetURL: "https://cdn.example/" + code + ".png",
OperatorUserID: 9001,
})
if err != nil {
t.Fatalf("create %s failed: %v", code, err)
}
return resource
}
vip6Skin := createMicSkin("vip6_read_sync_mic_skin")
vip8Skin := createMicSkin("vip8_read_sync_mic_skin")
repository.BindVIPBenefitResourceForApp(
"fami", 6, ledger.VipBenefitCodeMicSkin,
vip6Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
)
repository.BindVIPBenefitResourceForApp(
"fami", 8, ledger.VipBenefitCodeMicSkin,
vip8Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
)
repository.SetVIPLevelStatusForApp("fami", 8, ledger.VipStatusActive, 100)
const userID int64 = 880014
repository.SetBalanceForApp("fami", userID, 1_000)
if _, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip8-read-sync", UserID: userID, Level: 8,
}); err != nil {
t.Fatalf("purchase VIP8 failed: %v", err)
}
resources, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
})
if err != nil {
t.Fatalf("list VIP mic skins failed: %v", err)
}
var vip6EntitlementID string
for _, item := range resources {
if item.ResourceID == vip6Skin.ResourceID {
vip6EntitlementID = item.EntitlementID
break
}
}
if vip6EntitlementID == "" {
t.Fatalf("VIP8 purchase did not grant inherited VIP6 mic skin: %+v", resources)
}
if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
AppCode: "fami", CommandID: "equip-vip6-read-sync", UserID: userID,
ResourceID: vip6Skin.ResourceID, EntitlementID: vip6EntitlementID,
}); err != nil {
t.Fatalf("VIP8 user equip VIP6 mic skin failed: %v", err)
}
assertEquippedSkin := func(wantResourceID int64) {
t.Helper()
items, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
})
if err != nil {
t.Fatalf("list equipped VIP mic skin failed: %v", err)
}
var equippedResourceID int64
for _, item := range items {
if item.Equipped {
if equippedResourceID != 0 {
t.Fatalf("mic skin slot must remain single-select: %+v", items)
}
equippedResourceID = item.ResourceID
}
}
if equippedResourceID != wantResourceID {
t.Fatalf("equipped mic skin mismatch: got=%d want=%d resources=%+v", equippedResourceID, wantResourceID, items)
}
}
if _, err := service.GetVipState(ctx, userID); err != nil {
t.Fatalf("GetVipState after VIP6 selection failed: %v", err)
}
assertEquippedSkin(vip6Skin.ResourceID)
if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
t.Fatalf("ListVipPackages after VIP6 selection failed: %v", err)
}
assertEquippedSkin(vip6Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 0 {
t.Fatalf("read sync must not publish a default-equip event for a valid lower-level selection, got %d", got)
}
if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
AppCode: "fami", RequestID: "clear-vip-mic-before-first-sync", UserID: userID,
ResourceType: resourcedomain.TypeMicSeatIcon,
}); err != nil {
t.Fatalf("clear mic skin before first lazy recovery failed: %v", err)
}
if _, err := service.GetVipState(ctx, userID); err != nil {
t.Fatalf("first missing-slot lazy recovery failed: %v", err)
}
assertEquippedSkin(vip8Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 1 {
t.Fatalf("first missing-slot recovery must publish exactly one sync fact, got %d", got)
}
if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
AppCode: "fami", RequestID: "clear-vip-mic-before-replay", UserID: userID,
ResourceType: resourcedomain.TypeMicSeatIcon,
}); err != nil {
t.Fatalf("clear mic skin before lazy replay failed: %v", err)
}
if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
t.Fatalf("replayed missing-slot lazy recovery must not return duplicate-key error: %v", err)
}
assertEquippedSkin(vip8Skin.ResourceID)
if got := repository.CountRows(
"wallet_outbox",
"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
); got != 1 {
t.Fatalf("replayed sync fact must retain one immutable outbox row, got %d", got)
}
}
// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前: // TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前:
// 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。 // 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。
func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) { func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {

View File

@ -59,12 +59,13 @@ func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events
if err != nil { if err != nil {
return err return err
} }
eventAppCode := normalizedEntryAppCode(ctx, event.AppCode)
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO wallet_outbox ( `INSERT INTO wallet_outbox (
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
normalizedEntryAppCode(ctx, event.AppCode), eventAppCode,
event.EventID, event.EventID,
event.EventType, event.EventType,
event.TransactionID, event.TransactionID,
@ -78,7 +79,40 @@ func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events
event.CreatedAtMS, event.CreatedAtMS,
event.CreatedAtMS, event.CreatedAtMS,
); err != nil { ); err != nil {
return err if !isMySQLDuplicateError(err) {
return err
}
// event_id 是 outbox 事实的幂等键。读取时懒同步、事务重试或并发首发可能再次
// 生成同一事实;保留首次落库的 payload/发生时间/投递状态,不把重放改成新事件。
// 锁定点查同时防止哈希键被不同核心语义复用,避免 INSERT IGNORE 吞掉真实冲突。
var stored walletOutboxEvent
if scanErr := tx.QueryRowContext(ctx, `
SELECT event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta
FROM wallet_outbox
WHERE app_code = ? AND event_id = ?
FOR UPDATE`,
eventAppCode, event.EventID,
).Scan(
&stored.EventType,
&stored.TransactionID,
&stored.CommandID,
&stored.UserID,
&stored.AssetType,
&stored.AvailableDelta,
&stored.FrozenDelta,
); scanErr != nil {
return scanErr
}
if stored.EventType != event.EventType ||
stored.TransactionID != event.TransactionID ||
stored.CommandID != event.CommandID ||
stored.UserID != event.UserID ||
stored.AssetType != event.AssetType ||
stored.AvailableDelta != event.AvailableDelta ||
stored.FrozenDelta != event.FrozenDelta {
return xerr.New(xerr.LedgerConflict, "wallet outbox event id conflicts with a different fact")
}
} }
} }
return nil return nil

View File

@ -290,10 +290,10 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx
return entitlementID, true, nil return entitlementID, true, nil
} }
// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level 或已失效目录的 VIP 装备, // reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level、目录失效或 entitlement
// 再只按当前等级 benefit.auto_equip 恢复默认 entitlement。paid/trial 即使绑定同一 resource_id // 已失效的 VIP 装备,再按当前等级 benefit.auto_equip 恢复空槽位。preserveValidSelection 用于
// 也会切换到各自 source grant避免任一来源到期影响另一来源 // 纯读取懒同步:合法低等级素材和普通用户选择都必须保留;首次生效或升级则传 false明确切到新等级默认素材
func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64) error { func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64, preserveValidSelection bool) error {
trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial
if effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial { if effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial {
level = 0 level = 0
@ -305,6 +305,11 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
result, err := tx.ExecContext(ctx, ` result, err := tx.ExecContext(ctx, `
DELETE equipment DELETE equipment
FROM user_resource_equipment AS equipment FROM user_resource_equipment AS equipment
LEFT JOIN user_resource_entitlements AS entitlement
ON entitlement.app_code = equipment.app_code
AND entitlement.user_id = equipment.user_id
AND entitlement.entitlement_id = equipment.entitlement_id
AND entitlement.resource_id = equipment.resource_id
WHERE equipment.app_code = ? AND equipment.user_id = ? WHERE equipment.app_code = ? AND equipment.user_id = ?
AND ( AND (
EXISTS ( EXISTS (
@ -322,21 +327,28 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
AND historical.program_type = ? AND historical.program_type = ?
) )
) )
AND NOT EXISTS ( AND (
SELECT 1 entitlement.entitlement_id IS NULL
FROM vip_level_benefits AS current_benefit OR entitlement.status <> 'active'
JOIN resources AS current_resource ON current_resource.app_code = current_benefit.app_code OR entitlement.effective_at_ms > ?
AND current_resource.resource_id = current_benefit.resource_id OR (entitlement.expires_at_ms <> 0 AND entitlement.expires_at_ms <= ?)
AND current_resource.status = 'active' OR entitlement.remaining_quantity <= 0
AND current_resource.resource_type = current_benefit.resource_type OR NOT EXISTS (
WHERE current_benefit.app_code = equipment.app_code SELECT 1
AND current_benefit.level <= ? FROM vip_level_benefits AS current_benefit
AND current_benefit.status = 'active' JOIN resources AS current_resource ON current_resource.app_code = current_benefit.app_code
AND current_benefit.resource_id = equipment.resource_id AND current_resource.resource_id = current_benefit.resource_id
AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?)) AND current_resource.status = 'active'
AND current_resource.resource_type = current_benefit.resource_type
WHERE current_benefit.app_code = equipment.app_code
AND current_benefit.level <= ?
AND current_benefit.status = 'active'
AND current_benefit.resource_id = equipment.resource_id
AND (? = FALSE OR (current_benefit.trial_enabled = TRUE AND current_benefit.benefit_code <> ?))
)
)`, appcode.FromContext(ctx), userID, )`, appcode.FromContext(ctx), userID,
ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1,
level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate) nowMS, nowMS, level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
if err != nil { if err != nil {
return err return err
} }
@ -348,6 +360,13 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
if err != nil { if err != nil {
return err return err
} }
validEquippedTypes := map[string]struct{}{}
if preserveValidSelection && len(items) > 0 {
validEquippedTypes, err = r.listValidEquippedResourceTypesTx(ctx, tx, userID, nowMS)
if err != nil {
return err
}
}
events := make([]walletOutboxEvent, 0, len(items)+1) events := make([]walletOutboxEvent, 0, len(items)+1)
if removed > 0 { if removed > 0 {
events = append(events, resourceOutboxEvent("UserResourceChanged", boundedDerivedCommandID("vip_reconcile_remove:", commandID), userID, 0, map[string]any{ events = append(events, resourceOutboxEvent("UserResourceChanged", boundedDerivedCommandID("vip_reconcile_remove:", commandID), userID, 0, map[string]any{
@ -359,6 +378,14 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
if !item.AutoEquip || !isEquipableResourceType(item.Resource.ResourceType) { if !item.AutoEquip || !isEquipableResourceType(item.Resource.ResourceType) {
continue continue
} }
resourceType := resourcedomain.NormalizeResourceType(item.Resource.ResourceType)
if preserveValidSelection && !allowsMultipleEquippedResources(resourceType) {
if _, exists := validEquippedTypes[resourceType]; exists {
// 单选槽已有仍有效且当前 VIP 等级允许的素材时,读取接口只补 entitlement
// 不能把用户主动选择的低等级素材覆盖为当前等级默认值。
continue
}
}
entitlement, err := r.queryVipBenefitEntitlementForUpdateTx(ctx, tx, userID, item.Resource.ResourceID, effectiveSource, nowMS) entitlement, err := r.queryVipBenefitEntitlementForUpdateTx(ctx, tx, userID, item.Resource.ResourceID, effectiveSource, nowMS)
if xerr.IsCode(err, xerr.NotFound) { if xerr.IsCode(err, xerr.NotFound) {
// 调用方会先做 paid/trial 懒补齐;仍缺失说明目录或历史数据不完整,失败关闭展示但不阻塞状态读取。 // 调用方会先做 paid/trial 懒补齐;仍缺失说明目录或历史数据不完整,失败关闭展示但不阻塞状态读取。
@ -390,6 +417,47 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
return r.insertWalletOutbox(ctx, tx, events) return r.insertWalletOutbox(ctx, tx, events)
} }
// listValidEquippedResourceTypesTx 一次索引范围查询收集仍可保留的装备槽位。VIP 装备已由
// reconcile 的删除语句完成等级/目录门禁;这里仍校验 entitlement 和普通资源目录状态,
// 避免一个过期或已下架的非 VIP 指针阻止默认素材恢复。
func (r *Repository) listValidEquippedResourceTypesTx(ctx context.Context, tx *sql.Tx, userID int64, nowMS int64) (map[string]struct{}, error) {
rows, err := tx.QueryContext(ctx, `
SELECT DISTINCT equipment.resource_type
FROM user_resource_equipment AS equipment
JOIN user_resource_entitlements AS entitlement
ON entitlement.app_code = equipment.app_code
AND entitlement.user_id = equipment.user_id
AND entitlement.entitlement_id = equipment.entitlement_id
AND entitlement.resource_id = equipment.resource_id
LEFT JOIN resources AS resource
ON resource.app_code = equipment.app_code
AND resource.resource_id = equipment.resource_id
WHERE equipment.app_code = ? AND equipment.user_id = ?
AND entitlement.status = 'active'
AND entitlement.effective_at_ms <= ?
AND (entitlement.expires_at_ms = 0 OR entitlement.expires_at_ms > ?)
AND entitlement.remaining_quantity > 0
AND (
entitlement.source_snapshot_id <> ''
OR (resource.resource_id IS NOT NULL AND resource.status = 'active')
)`,
appcode.FromContext(ctx), userID, nowMS, nowMS,
)
if err != nil {
return nil, err
}
defer rows.Close()
resourceTypes := make(map[string]struct{}, 4)
for rows.Next() {
var resourceType string
if err := rows.Scan(&resourceType); err != nil {
return nil, err
}
resourceTypes[resourcedomain.NormalizeResourceType(resourceType)] = struct{}{}
}
return resourceTypes, rows.Err()
}
func (r *Repository) isUserResourceEntitlementEquippedTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement) (bool, error) { func (r *Repository) isUserResourceEntitlementEquippedTx(ctx context.Context, tx *sql.Tx, userID int64, entitlement resourcedomain.UserResourceEntitlement) (bool, error) {
var equipped bool var equipped bool
err := tx.QueryRowContext(ctx, ` err := tx.QueryRowContext(ctx, `
@ -631,7 +699,7 @@ func (r *Repository) ensureTieredVipResourceState(ctx context.Context, userID in
effectiveSource = state.EffectiveSource effectiveSource = state.EffectiveSource
} }
commandID := "vip_state_sync:" + stableHash(fmt.Sprintf("%s|%d|%d|%s|%d", appcode.FromContext(ctx), userID, effectiveLevel, effectiveSource, program.ConfigVersion)) commandID := "vip_state_sync:" + stableHash(fmt.Sprintf("%s|%d|%d|%s|%d", appcode.FromContext(ctx), userID, effectiveLevel, effectiveSource, program.ConfigVersion))
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, userID, effectiveLevel, effectiveSource, commandID, nowMS); err != nil { if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, userID, effectiveLevel, effectiveSource, commandID, nowMS, true); err != nil {
return err return err
} }
return tx.Commit() return tx.Commit()

View File

@ -329,7 +329,11 @@ func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vi
effectiveLevel = state.EffectiveVip.Level effectiveLevel = state.EffectiveVip.Level
effectiveSource = state.EffectiveSource effectiveSource = state.EffectiveSource
} }
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, effectiveLevel, effectiveSource, command.CommandID, command.NowMS); err != nil { // 只有首次生效或 paid 等级提升时才主动切换默认素材;同等级续费以及更高
// trial 仍生效时必须保留用户当前合法选择。
preserveValidSelection := state.EffectiveSource != ledger.VipEffectiveSourcePaid ||
(command.Current.Active && command.Level.Level <= command.Current.Level)
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, effectiveLevel, effectiveSource, command.CommandID, command.NowMS, preserveValidSelection); err != nil {
return vipActivationResult{}, err return vipActivationResult{}, err
} }
} }

View File

@ -590,7 +590,9 @@ func (r *Repository) EquipVipTrialCard(ctx context.Context, command ledger.Equip
if err := r.grantVipTrialBenefitResourcesTx(ctx, tx, card, program.ConfigVersion, nowMS); err != nil { if err := r.grantVipTrialBenefitResourcesTx(ctx, tx, card, program.ConfigVersion, nowMS); err != nil {
return ledger.VipTrialCard{}, ledger.VipState{}, err return ledger.VipTrialCard{}, ledger.VipState{}, err
} }
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, card.Level, ledger.VipEffectiveSourceTrial, command.RequestID, nowMS); err != nil { // 首次佩戴或等级提升时采用新等级默认素材;同等级卡片切换仍尊重当前合法选择。
preserveValidSelection := currentEffectiveLevel > 0 && card.Level <= currentEffectiveLevel
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, card.Level, ledger.VipEffectiveSourceTrial, command.RequestID, nowMS, preserveValidSelection); err != nil {
return ledger.VipTrialCard{}, ledger.VipState{}, err return ledger.VipTrialCard{}, ledger.VipState{}, err
} }
} }
@ -657,7 +659,7 @@ func (r *Repository) UnequipVipTrialCard(ctx context.Context, command ledger.Une
if effectiveLevel > 0 { if effectiveLevel > 0 {
effectiveSource = ledger.VipEffectiveSourcePaid effectiveSource = ledger.VipEffectiveSourcePaid
} }
if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, effectiveLevel, effectiveSource, command.RequestID, nowMS); err != nil { if err := r.reconcileTieredVipResourceEquipmentTx(ctx, tx, command.UserID, effectiveLevel, effectiveSource, command.RequestID, nowMS, true); err != nil {
return false, ledger.VipState{}, err return false, ledger.VipState{}, err
} }
} }