vip问题,以及每日任务相关
This commit is contained in:
parent
9ef8df22f0
commit
1069d95e9b
@ -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_resource_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_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期毫秒,0 为永久',
|
||||
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_resource_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_duration_ms BIGINT NOT NULL DEFAULT 0 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_resource_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_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
|
||||
reward_items_json JSON NULL COMMENT '奖励展示快照',
|
||||
|
||||
@ -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;
|
||||
@ -99,6 +99,8 @@ type Definition struct {
|
||||
RewardAssetType string
|
||||
RewardResourceID int64
|
||||
RewardResourceGroupID int64
|
||||
RewardSnapshotID string
|
||||
RewardSnapshotHash string
|
||||
RewardQuantity int64
|
||||
RewardDurationMS int64
|
||||
RewardItemsJSON string
|
||||
@ -128,6 +130,8 @@ type Progress struct {
|
||||
RewardAssetType string
|
||||
RewardResourceID int64
|
||||
RewardResourceGroupID int64
|
||||
RewardSnapshotID string
|
||||
RewardSnapshotHash string
|
||||
RewardQuantity int64
|
||||
RewardDurationMS int64
|
||||
RewardItemsJSON string
|
||||
@ -195,6 +199,8 @@ type DefinitionCommand struct {
|
||||
RewardAssetType string
|
||||
RewardResourceID int64
|
||||
RewardResourceGroupID int64
|
||||
RewardSnapshotID string
|
||||
RewardSnapshotHash string
|
||||
RewardQuantity int64
|
||||
RewardDurationMS int64
|
||||
RewardItemsJSON string
|
||||
@ -246,6 +252,8 @@ type Claim struct {
|
||||
RewardAssetType string
|
||||
RewardResourceID int64
|
||||
RewardResourceGroupID int64
|
||||
RewardSnapshotID string
|
||||
RewardSnapshotHash string
|
||||
RewardQuantity int64
|
||||
RewardDurationMS int64
|
||||
RewardItemsJSON string
|
||||
|
||||
@ -2,8 +2,11 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -39,8 +42,10 @@ type WalletClient interface {
|
||||
type ResourceWalletClient interface {
|
||||
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)
|
||||
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)
|
||||
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 承载任务查询、事件消费、领奖和后台配置用例。
|
||||
@ -208,14 +213,34 @@ func (s *Service) grantClaimReward(ctx context.Context, claim taskdomain.Claim,
|
||||
if s.resourceWallet == nil {
|
||||
return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
|
||||
}
|
||||
resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||
CommandId: claim.WalletCommandID,
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TargetUserId: claim.UserID,
|
||||
GroupId: claim.RewardResourceGroupID,
|
||||
Reason: reason,
|
||||
OperatorUserId: claim.UserID,
|
||||
GrantSource: "daily_task",
|
||||
snapshotID := strings.TrimSpace(claim.RewardSnapshotID)
|
||||
snapshotHash := strings.ToLower(strings.TrimSpace(claim.RewardSnapshotHash))
|
||||
if snapshotID == "" && snapshotHash == "" {
|
||||
// 兼容 016 上线前已产生进度的短暂历史数据:旧记录没有 owner 快照引用,只能按当时的 group_id 走原有赠送。
|
||||
// 新配置一律在保存时 pin,迁移默认空值不能伪造快照;该分支可在确认旧进度清空后再下线。
|
||||
resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||
CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID,
|
||||
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 {
|
||||
return "", "", 0, err
|
||||
@ -428,6 +453,8 @@ func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdom
|
||||
def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType)
|
||||
def.RewardResourceID = progress.RewardResourceID
|
||||
def.RewardResourceGroupID = progress.RewardResourceGroupID
|
||||
def.RewardSnapshotID = progress.RewardSnapshotID
|
||||
def.RewardSnapshotHash = progress.RewardSnapshotHash
|
||||
def.RewardQuantity = progress.RewardQuantity
|
||||
def.RewardDurationMS = progress.RewardDurationMS
|
||||
def.RewardItemsJSON = progress.RewardItemsJSON
|
||||
@ -621,6 +648,8 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
|
||||
// 非当前奖励类型字段必须清空,避免任务从资源切回金币后旧 ID 继续出现在 Admin/App 响应中。
|
||||
command.RewardResourceID = 0
|
||||
command.RewardResourceGroupID = 0
|
||||
command.RewardSnapshotID = ""
|
||||
command.RewardSnapshotHash = ""
|
||||
command.RewardQuantity = 0
|
||||
command.RewardDurationMS = 0
|
||||
return withRewardItems(command, []taskdomain.RewardItem{{
|
||||
@ -647,6 +676,8 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
|
||||
command.RewardCoinAmount = 0
|
||||
command.RewardAssetType = ""
|
||||
command.RewardResourceGroupID = 0
|
||||
command.RewardSnapshotID = ""
|
||||
command.RewardSnapshotHash = ""
|
||||
return withRewardItems(command, []taskdomain.RewardItem{item})
|
||||
case taskdomain.RewardTypeResourceGroup:
|
||||
if s.resourceWallet == nil {
|
||||
@ -661,14 +692,43 @@ func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.Def
|
||||
return taskdomain.DefinitionCommand{}, err
|
||||
}
|
||||
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")
|
||||
}
|
||||
if len(group.GetItems()) == 0 {
|
||||
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group has no items")
|
||||
}
|
||||
items := make([]taskdomain.RewardItem, 0, len(group.GetItems()))
|
||||
for _, groupItem := range group.GetItems() {
|
||||
pinKey := rewardSnapshotPinKey(ctx, command.RewardResourceGroupID, sourceContentHash)
|
||||
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 groupItem.GetWalletAssetType() == "" || groupItem.GetWalletAssetAmount() <= 0 {
|
||||
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.RewardAssetType = ""
|
||||
command.RewardResourceID = 0
|
||||
command.RewardSnapshotID = snapshot.GetSnapshotId()
|
||||
command.RewardSnapshotHash = snapshotHash
|
||||
command.RewardQuantity = 0
|
||||
command.RewardDurationMS = 0
|
||||
return withRewardItems(command, items)
|
||||
@ -739,6 +801,21 @@ func rewardCatalogRequestID(command taskdomain.DefinitionCommand) string {
|
||||
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) {
|
||||
// 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
|
||||
payload := map[string]any{
|
||||
|
||||
@ -410,8 +410,9 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &fakeWalletClient{
|
||||
group: &walletv1.ResourceGroup{
|
||||
GroupId: 9901,
|
||||
Status: "active",
|
||||
GroupId: 9901,
|
||||
Status: "active",
|
||||
UpdatedAtMs: 1778291000000,
|
||||
Items: []*walletv1.ResourceGroupItem{
|
||||
{
|
||||
ItemType: "resource",
|
||||
@ -471,7 +472,9 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
|
||||
if len(rewardItems) != 3 ||
|
||||
rewardItems[0].CoverURL != "https://cdn.example.com/rewards/frame.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)
|
||||
}
|
||||
|
||||
@ -496,9 +499,10 @@ func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("claim resource group task failed: %v", err)
|
||||
}
|
||||
if claim.WalletResourceGrantID != "grant-task-1" || wallet.groupGrantCalls != 1 ||
|
||||
wallet.lastGroupGrant.GetGroupId() != 9901 {
|
||||
t.Fatalf("resource group claim mismatch: claim=%+v wallet=%+v", claim, wallet.lastGroupGrant)
|
||||
if claim.WalletResourceGrantID != "grant-task-1" || wallet.pinnedGroupGrantCalls != 1 ||
|
||||
wallet.lastPinnedGroupGrant.GetSnapshotId() != "snapshot-task-group-1" ||
|
||||
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 {
|
||||
calls int
|
||||
last *walletv1.CreditTaskRewardRequest
|
||||
resource *walletv1.Resource
|
||||
group *walletv1.ResourceGroup
|
||||
resourceGrantCalls int
|
||||
groupGrantCalls int
|
||||
lastResourceGrant *walletv1.GrantResourceRequest
|
||||
lastGroupGrant *walletv1.GrantResourceGroupRequest
|
||||
calls int
|
||||
last *walletv1.CreditTaskRewardRequest
|
||||
resource *walletv1.Resource
|
||||
group *walletv1.ResourceGroup
|
||||
resourceGrantCalls int
|
||||
pinnedGroupGrantCalls int
|
||||
lastResourceGrant *walletv1.GrantResourceRequest
|
||||
lastPinnedGroupGrant *walletv1.GrantPinnedResourceGroupRequest
|
||||
}
|
||||
|
||||
const fakeTaskRewardSnapshotHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
|
||||
f.calls++
|
||||
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) {
|
||||
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) {
|
||||
@ -714,9 +731,17 @@ func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantR
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
|
||||
f.groupGrantCalls++
|
||||
f.lastGroupGrant = req
|
||||
func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, _ *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
|
||||
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{
|
||||
GrantId: "grant-task-1",
|
||||
CreatedAtMs: 1778292000000,
|
||||
|
||||
@ -27,7 +27,8 @@ func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([
|
||||
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
|
||||
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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, `
|
||||
SELECT 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, 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
|
||||
FROM user_task_progress
|
||||
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(dimension_filter_json AS CHAR), '{}'),
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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.RewardResourceID = command.RewardResourceID
|
||||
next.RewardResourceGroupID = command.RewardResourceGroupID
|
||||
next.RewardSnapshotID = command.RewardSnapshotID
|
||||
next.RewardSnapshotHash = command.RewardSnapshotHash
|
||||
next.RewardQuantity = command.RewardQuantity
|
||||
next.RewardDurationMS = command.RewardDurationMS
|
||||
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 = ?,
|
||||
action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON),
|
||||
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 = ?,
|
||||
version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_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.ActionPayloadJSON, next.DimensionFilterJSON,
|
||||
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.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS,
|
||||
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,
|
||||
RewardResourceID: progress.RewardResourceID,
|
||||
RewardResourceGroupID: progress.RewardResourceGroupID,
|
||||
RewardSnapshotID: progress.RewardSnapshotID,
|
||||
RewardSnapshotHash: progress.RewardSnapshotHash,
|
||||
RewardQuantity: progress.RewardQuantity,
|
||||
RewardDurationMS: progress.RewardDurationMS,
|
||||
RewardItemsJSON: progress.RewardItemsJSON,
|
||||
@ -391,12 +400,14 @@ func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.Cl
|
||||
INSERT INTO task_reward_claims (
|
||||
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_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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, '', ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, '', ?, ?)`,
|
||||
appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType,
|
||||
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,
|
||||
); err != nil {
|
||||
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(dimension_filter_json AS CHAR), '{}'),
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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 (
|
||||
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,
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, 0, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, 0, ?)`,
|
||||
appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta,
|
||||
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,
|
||||
)
|
||||
return err == nil, err
|
||||
@ -593,6 +607,8 @@ func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, comma
|
||||
RewardAssetType: command.RewardAssetType,
|
||||
RewardResourceID: command.RewardResourceID,
|
||||
RewardResourceGroupID: command.RewardResourceGroupID,
|
||||
RewardSnapshotID: command.RewardSnapshotID,
|
||||
RewardSnapshotHash: command.RewardSnapshotHash,
|
||||
RewardQuantity: command.RewardQuantity,
|
||||
RewardDurationMS: command.RewardDurationMS,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL,
|
||||
definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON,
|
||||
definition.TargetValue, definition.TargetUnit,
|
||||
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.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS,
|
||||
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(dimension_filter_json AS CHAR), '{}'),
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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(dimension_filter_json AS CHAR), '{}'),
|
||||
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,
|
||||
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
|
||||
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, `
|
||||
SELECT 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, 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
|
||||
FROM user_task_progress
|
||||
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.RewardResourceID,
|
||||
&definition.RewardResourceGroupID,
|
||||
&definition.RewardSnapshotID,
|
||||
&definition.RewardSnapshotHash,
|
||||
&definition.RewardQuantity,
|
||||
&definition.RewardDurationMS,
|
||||
&definition.RewardItemsJSON,
|
||||
@ -818,6 +841,8 @@ func scanProgress(row rowScanner) (taskdomain.Progress, error) {
|
||||
&progress.RewardType,
|
||||
&progress.RewardResourceID,
|
||||
&progress.RewardResourceGroupID,
|
||||
&progress.RewardSnapshotID,
|
||||
&progress.RewardSnapshotHash,
|
||||
&progress.RewardQuantity,
|
||||
&progress.RewardDurationMS,
|
||||
&progress.RewardItemsJSON,
|
||||
@ -843,6 +868,8 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
|
||||
&claim.RewardType,
|
||||
&claim.RewardResourceID,
|
||||
&claim.RewardResourceGroupID,
|
||||
&claim.RewardSnapshotID,
|
||||
&claim.RewardSnapshotHash,
|
||||
&claim.RewardQuantity,
|
||||
&claim.RewardDurationMS,
|
||||
&claim.RewardItemsJSON,
|
||||
@ -859,7 +886,8 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
|
||||
|
||||
func taskClaimSelectSQL() string {
|
||||
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,
|
||||
wallet_resource_grant_id, status, failure_reason, created_at_ms, updated_at_ms
|
||||
FROM task_reward_claims `
|
||||
@ -902,6 +930,8 @@ func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.D
|
||||
current.RewardType != next.RewardType ||
|
||||
current.RewardResourceID != next.RewardResourceID ||
|
||||
current.RewardResourceGroupID != next.RewardResourceGroupID ||
|
||||
current.RewardSnapshotID != next.RewardSnapshotID ||
|
||||
current.RewardSnapshotHash != next.RewardSnapshotHash ||
|
||||
current.RewardQuantity != next.RewardQuantity ||
|
||||
current.RewardDurationMS != next.RewardDurationMS ||
|
||||
current.RewardItemsJSON != next.RewardItemsJSON
|
||||
|
||||
@ -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 验证幂等守卫发生在账户/会员锁之前:
|
||||
// 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。
|
||||
func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {
|
||||
|
||||
@ -59,12 +59,13 @@ func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventAppCode := normalizedEntryAppCode(ctx, event.AppCode)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO wallet_outbox (
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
||||
normalizedEntryAppCode(ctx, event.AppCode),
|
||||
eventAppCode,
|
||||
event.EventID,
|
||||
event.EventType,
|
||||
event.TransactionID,
|
||||
@ -78,7 +79,40 @@ func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events
|
||||
event.CreatedAtMS,
|
||||
event.CreatedAtMS,
|
||||
); 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
|
||||
|
||||
@ -290,10 +290,10 @@ func (r *Repository) findExistingVipBenefitEntitlementTx(ctx context.Context, tx
|
||||
return entitlementID, true, nil
|
||||
}
|
||||
|
||||
// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level 或已失效目录的 VIP 装备,
|
||||
// 再只按当前等级 benefit.auto_equip 恢复默认 entitlement。paid/trial 即使绑定同一 resource_id,
|
||||
// 也会切换到各自 source grant,避免任一来源到期影响另一来源。
|
||||
func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context, tx *sql.Tx, userID int64, level int32, effectiveSource string, commandID string, nowMS int64) error {
|
||||
// reconcileTieredVipResourceEquipmentTx 删除高于最终 effective level、目录失效或 entitlement
|
||||
// 已失效的 VIP 装备,再按当前等级 benefit.auto_equip 恢复空槽位。preserveValidSelection 用于
|
||||
// 纯读取懒同步:合法低等级素材和普通用户选择都必须保留;首次生效或升级则传 false,明确切到新等级默认素材。
|
||||
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
|
||||
if effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial {
|
||||
level = 0
|
||||
@ -305,6 +305,11 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
DELETE 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 = ?
|
||||
AND (
|
||||
EXISTS (
|
||||
@ -322,21 +327,28 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
AND historical.program_type = ?
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vip_level_benefits AS current_benefit
|
||||
JOIN resources AS current_resource ON current_resource.app_code = current_benefit.app_code
|
||||
AND current_resource.resource_id = current_benefit.resource_id
|
||||
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 <> ?))
|
||||
AND (
|
||||
entitlement.entitlement_id IS NULL
|
||||
OR entitlement.status <> 'active'
|
||||
OR entitlement.effective_at_ms > ?
|
||||
OR (entitlement.expires_at_ms <> 0 AND entitlement.expires_at_ms <= ?)
|
||||
OR entitlement.remaining_quantity <= 0
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM vip_level_benefits AS current_benefit
|
||||
JOIN resources AS current_resource ON current_resource.app_code = current_benefit.app_code
|
||||
AND current_resource.resource_id = current_benefit.resource_id
|
||||
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,
|
||||
ledger.VipProgramTypeTieredPrivilegeV1, ledger.VipProgramTypeTieredPrivilegeV1,
|
||||
level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
|
||||
nowMS, nowMS, level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -348,6 +360,13 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
if err != nil {
|
||||
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)
|
||||
if removed > 0 {
|
||||
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) {
|
||||
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)
|
||||
if xerr.IsCode(err, xerr.NotFound) {
|
||||
// 调用方会先做 paid/trial 懒补齐;仍缺失说明目录或历史数据不完整,失败关闭展示但不阻塞状态读取。
|
||||
@ -390,6 +417,47 @@ func (r *Repository) reconcileTieredVipResourceEquipmentTx(ctx context.Context,
|
||||
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) {
|
||||
var equipped bool
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
@ -631,7 +699,7 @@ func (r *Repository) ensureTieredVipResourceState(ctx context.Context, userID in
|
||||
effectiveSource = state.EffectiveSource
|
||||
}
|
||||
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 tx.Commit()
|
||||
|
||||
@ -329,7 +329,11 @@ func (r *Repository) activateUserVip(ctx context.Context, tx *sql.Tx, command vi
|
||||
effectiveLevel = state.EffectiveVip.Level
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -657,7 +659,7 @@ func (r *Repository) UnequipVipTrialCard(ctx context.Context, command ledger.Une
|
||||
if effectiveLevel > 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user