取消vip门槛

This commit is contained in:
zhx 2026-07-04 02:17:43 +08:00
parent 0a072894ec
commit 7a2d292c36
12 changed files with 82 additions and 210 deletions

View File

@ -1,12 +0,0 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
# Local RocketMQ clients run in two places: Docker containers and host go run
# processes. Advertising the Docker host address keeps both paths reachable.
brokerIP1 = host.docker.internal
listenPort = 10911

View File

@ -1,92 +0,0 @@
{"at_ms":1780381127156,"mode":"dry-run","to_account":"424242","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"12345678","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585934","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585935","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586014","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586015","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586332","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586333","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971589","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971590","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971617","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971618","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971718","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971719","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651195","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651196","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651197","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651198","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935471","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935472","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935473","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935474","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936181","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936182","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936183","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936184","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912277","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912278","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864464","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864465","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047904","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047905","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064123","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064124","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778061358","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778062287","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"991205120003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991803","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991804","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196761","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196762","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865153","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865154","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865155","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865156","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865157","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865158","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865159","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865160","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865161","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865162","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318705991371722752","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707093894860800","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707097803952128","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707100643495936","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707103256547328","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707105987039232","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"320078559995498496","status":"dry_run"}

View File

@ -0,0 +1,6 @@
USE hyapp_wallet;
-- VIP 购买已改为所有等级直接按金币购买;保留字段只为兼容旧协议,线上已有累充门槛统一清零。
UPDATE vip_levels
SET required_recharge_coin_amount = 0
WHERE required_recharge_coin_amount <> 0;

View File

@ -139,13 +139,14 @@ func (h *Handler) UpdateLevels(c *gin.Context) {
input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels)) input := make([]*walletv1.AdminVipLevelInput, 0, len(req.Levels))
for _, level := range req.Levels { for _, level := range req.Levels {
input = append(input, &walletv1.AdminVipLevelInput{ input = append(input, &walletv1.AdminVipLevelInput{
Level: level.Level, Level: level.Level,
Name: strings.TrimSpace(level.Name), Name: strings.TrimSpace(level.Name),
Status: strings.TrimSpace(level.Status), Status: strings.TrimSpace(level.Status),
PriceCoin: level.PriceCoin, PriceCoin: level.PriceCoin,
DurationMs: level.DurationMS, DurationMs: level.DurationMS,
RewardResourceGroupId: level.RewardResourceGroupID, RewardResourceGroupId: level.RewardResourceGroupID,
RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount, // VIP 累充门槛已下线;后台旧请求即使携带旧字段,也统一写 0避免旧页面或缓存把门槛重新保存回来。
RequiredRechargeCoinAmount: 0,
SortOrder: level.SortOrder, SortOrder: level.SortOrder,
}) })
} }
@ -170,16 +171,17 @@ func vipLevelsFromProto(items []*walletv1.VipLevel) []vipLevelDTO {
continue continue
} }
levels = append(levels, vipLevelDTO{ levels = append(levels, vipLevelDTO{
Level: item.GetLevel(), Level: item.GetLevel(),
Name: item.GetName(), Name: item.GetName(),
Status: item.GetStatus(), Status: item.GetStatus(),
PriceCoin: item.GetPriceCoin(), PriceCoin: item.GetPriceCoin(),
DurationMS: item.GetDurationMs(), DurationMS: item.GetDurationMs(),
RewardResourceGroupID: item.GetRewardResourceGroupId(), RewardResourceGroupID: item.GetRewardResourceGroupId(),
SortOrder: item.GetSortOrder(), SortOrder: item.GetSortOrder(),
RechargeGateRequired: item.GetRechargeGateRequired(), // 响应仍保留兼容字段,但后台配置页不再展示或依赖累充资格,统一返回无门槛。
RequiredRechargeCoinAmount: item.GetRequiredRechargeCoinAmount(), RechargeGateRequired: false,
UserRechargeCoinAmount: item.GetUserRechargeCoinAmount(), RequiredRechargeCoinAmount: 0,
UserRechargeCoinAmount: 0,
PurchaseLockedReason: item.GetPurchaseLockedReason(), PurchaseLockedReason: item.GetPurchaseLockedReason(),
CreatedAtMS: item.GetCreatedAtMs(), CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(), UpdatedAtMS: item.GetUpdatedAtMs(),

View File

@ -356,30 +356,30 @@ func TestGrantManagerResourceRejectsCrossCountryTarget(t *testing.T) {
} }
} }
func TestGrantManagerVIPLimitsToVipOneToThree(t *testing.T) { func TestGrantManagerVIPRejectsLevelAboveManagerLimit(t *testing.T) {
walletClient := &fakeWalletClient{} walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{}) router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-vip-4","target_user_id":"900001","level":4}`) body := []byte(`{"command_id":"mgr-vip-6","target_user_id":"900001","level":6}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body)) request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-vip4") request.Header.Set("X-Request-ID", "req-manager-vip6")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-manager-vip4") assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-manager-vip6")
if walletClient.lastGrantVip != nil { if walletClient.lastGrantVip != nil {
t.Fatalf("VIP4 must not reach wallet-service: %+v", walletClient.lastGrantVip) t.Fatalf("VIP6 must not reach wallet-service: %+v", walletClient.lastGrantVip)
} }
} }
func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) { func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
walletClient := &fakeWalletClient{} walletClient := &fakeWalletClient{}
router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{}) router := newManagerCenterTestRouter(walletClient, &fakeUserHostClient{}, &fakeUserProfileClient{})
body := []byte(`{"command_id":"mgr-vip-3","target_user_id":"900001","level":3}`) body := []byte(`{"command_id":"mgr-vip-5","target_user_id":"900001","level":5}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body)) request := httptest.NewRequest(http.MethodPost, "/api/v1/manager-center/vip-grants", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-manager-vip3") request.Header.Set("X-Request-ID", "req-manager-vip5")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@ -387,7 +387,7 @@ func TestGrantManagerVIPUsesManagerCenterGrantSource(t *testing.T) {
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
} }
if req := walletClient.lastGrantVip; req == nil || req.GetCommandId() != "mgr-vip-3" || req.GetTargetUserId() != 900001 || req.GetLevel() != 3 || req.GetGrantSource() != "manager_center" || req.GetOperatorUserId() != 42 { if req := walletClient.lastGrantVip; req == nil || req.GetCommandId() != "mgr-vip-5" || req.GetTargetUserId() != 900001 || req.GetLevel() != 5 || req.GetGrantSource() != "manager_center" || req.GetOperatorUserId() != 42 {
t.Fatalf("wallet GrantVip request mismatch: %+v", req) t.Fatalf("wallet GrantVip request mismatch: %+v", req)
} }
} }

View File

@ -29,6 +29,7 @@ const managerBlockUserCapability = "manager_block_user"
const managerTransferUserCountryCapability = "manager_transfer_user_country" const managerTransferUserCountryCapability = "manager_transfer_user_country"
const managerGrantDayMS int64 = 24 * 60 * 60 * 1000 const managerGrantDayMS int64 = 24 * 60 * 60 * 1000
const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS const managerGrantDefaultDurationMS int64 = 7 * managerGrantDayMS
const managerGrantMaxVIPLevel int32 = 5
type managerGrantResourceData struct { type managerGrantResourceData struct {
ResourceID string `json:"resource_id"` ResourceID string `json:"resource_id"`
@ -397,7 +398,8 @@ func (h *Handler) grantManagerVIP(writer http.ResponseWriter, request *http.Requ
commandID = strings.TrimSpace(body.CommandIDAlt) commandID = strings.TrimSpace(body.CommandIDAlt)
} }
targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt) targetUserID, targetOK := rawInt64(body.TargetUserID, body.TargetUserIDAlt)
if commandID == "" || !targetOK || targetUserID <= 0 || body.Level < 1 || body.Level > 3 { // 经理中心只放开 VIP1-5 的运营赠送入口;钱包仍按 vip_levels.status 做最终配置校验,避免 H5 放开后绕过等级启停。
if commandID == "" || !targetOK || targetUserID <= 0 || body.Level < 1 || body.Level > managerGrantMaxVIPLevel {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return return
} }

View File

@ -1425,7 +1425,7 @@ CREATE TABLE IF NOT EXISTS vip_levels (
price_coin BIGINT NOT NULL COMMENT 'VIP 价格金币数', price_coin BIGINT NOT NULL COMMENT 'VIP 价格金币数',
duration_ms BIGINT NOT NULL COMMENT '持续时长,毫秒', duration_ms BIGINT NOT NULL COMMENT '持续时长,毫秒',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '奖励资源分组 ID', reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '奖励资源分组 ID',
required_recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '购买所需累计充值金币1-5 级固定为 06-10 级必须配置', required_recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧 VIP 累充门槛字段,当前不参与购买资格',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前', sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -1435,7 +1435,7 @@ CREATE TABLE IF NOT EXISTS vip_levels (
SET @ddl := IF( SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_levels' AND COLUMN_NAME = 'required_recharge_coin_amount') = 0, (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'vip_levels' AND COLUMN_NAME = 'required_recharge_coin_amount') = 0,
'ALTER TABLE vip_levels ADD COLUMN required_recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''购买所需累计充值金币1-5 级固定为 06-10 级必须配置'' AFTER reward_resource_group_id', 'ALTER TABLE vip_levels ADD COLUMN required_recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''兼容旧 VIP 累充门槛字段,当前不参与购买资格'' AFTER reward_resource_group_id',
'SELECT 1' 'SELECT 1'
); );
PREPARE stmt FROM @ddl; PREPARE stmt FROM @ddl;

View File

@ -5480,8 +5480,8 @@ func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
} }
} }
// TestVipLevelSixRequiresRechargeThreshold 验证 6-10 级 VIP 必须先达到累计充值门槛 // TestVipLevelSixIgnoresLegacyRechargeThreshold 验证 VIP6 即使存在旧累充字段,也能按金币价格直接购买
func TestVipLevelSixRequiresRechargeThreshold(t *testing.T) { func TestVipLevelSixIgnoresLegacyRechargeThreshold(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetBalance(51002, 50000) repository.SetBalance(51002, 50000)
svc := walletservice.New(repository) svc := walletservice.New(repository)
@ -5517,32 +5517,15 @@ func TestVipLevelSixRequiresRechargeThreshold(t *testing.T) {
if _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil { if _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil {
t.Fatalf("UpdateAdminVipLevels failed: %v", err) t.Fatalf("UpdateAdminVipLevels failed: %v", err)
} }
repository.SetVIPLevelRechargeThreshold(6, 5000)
_, packages, err := svc.ListVipPackages(context.Background(), 51002) _, packages, err := svc.ListVipPackages(context.Background(), 51002)
if err != nil { if err != nil {
t.Fatalf("ListVipPackages failed: %v", err) t.Fatalf("ListVipPackages failed: %v", err)
} }
level6 := vipLevelByNumber(packages, 6) level6 := vipLevelByNumber(packages, 6)
if level6 == nil || level6.CanPurchase || level6.PurchaseLockedReason != "recharge_required" || level6.RequiredRechargeCoinAmount != 5000 { if level6 == nil || !level6.CanPurchase || level6.PurchaseLockedReason != "" || level6.RechargeGateRequired || level6.RequiredRechargeCoinAmount != 0 || level6.UserRechargeCoinAmount != 0 {
t.Fatalf("vip level 6 should be locked by recharge gate: %+v", level6) t.Fatalf("vip level 6 should ignore legacy recharge gate: %+v", level6)
}
_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip6-locked",
UserID: 51002,
Level: 6,
})
if !xerr.IsCode(err, xerr.VIPRechargeRequired) {
t.Fatalf("expected VIP_RECHARGE_REQUIRED, got %v", err)
}
repository.SetRechargeStats(51002, 5000)
_, packages, err = svc.ListVipPackages(context.Background(), 51002)
if err != nil {
t.Fatalf("ListVipPackages after recharge failed: %v", err)
}
level6 = vipLevelByNumber(packages, 6)
if level6 == nil || !level6.CanPurchase || level6.UserRechargeCoinAmount != 5000 {
t.Fatalf("vip level 6 should be purchasable after recharge: %+v", level6)
} }
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip6", CommandID: "cmd-vip6",

View File

@ -89,9 +89,8 @@ func normalizeAdminVipLevelCommands(levels []ledger.AdminVipLevelCommand) ([]led
if _, exists := byLevel[level.Level]; exists { if _, exists := byLevel[level.Level]; exists {
return nil, xerr.New(xerr.InvalidArgument, "vip level is duplicated") return nil, xerr.New(xerr.InvalidArgument, "vip level is duplicated")
} }
if level.Level <= 5 { // 累充门槛字段仅保留数据库和 proto 兼容性;后台保存任意等级时都归零,防止旧客户端把历史门槛重新写入生效数据。
level.RequiredRechargeCoinAmount = 0 level.RequiredRechargeCoinAmount = 0
}
if err := validateAdminVipLevelCommand(level); err != nil { if err := validateAdminVipLevelCommand(level); err != nil {
return nil, err return nil, err
} }
@ -129,9 +128,6 @@ func validateAdminVipLevelCommand(level ledger.AdminVipLevelCommand) error {
if level.RewardResourceGroupID <= 0 { if level.RewardResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "active vip level reward_resource_group_id is required") return xerr.New(xerr.InvalidArgument, "active vip level reward_resource_group_id is required")
} }
if level.Level >= 6 && level.RequiredRechargeCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "vip level 6-10 requires recharge threshold")
}
return nil return nil
} }

View File

@ -57,15 +57,6 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed") return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed")
} }
} }
if level.RechargeGateRequired {
totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, tx, command.UserID)
if err != nil {
return ledger.PurchaseVipReceipt{}, err
}
if totalRechargeCoin < level.RequiredRechargeCoinAmount {
return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPRechargeRequired, "vip recharge requirement is not met")
}
}
newExpiresAt := baseExpiresAt + level.DurationMS newExpiresAt := baseExpiresAt + level.DurationMS
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs) account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
if err != nil { if err != nil {
@ -79,17 +70,18 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
orderID := "vip_order_" + stableHash(command.AppCode+"|"+command.CommandID) orderID := "vip_order_" + stableHash(command.AppCode+"|"+command.CommandID)
coinBalanceAfter := account.AvailableAmount - level.PriceCoin coinBalanceAfter := account.AvailableAmount - level.PriceCoin
metadata := map[string]any{ metadata := map[string]any{
"app_code": command.AppCode, "app_code": command.AppCode,
"order_id": orderID, "order_id": orderID,
"user_id": command.UserID, "user_id": command.UserID,
"target_level": command.Level, "target_level": command.Level,
"previous_level": previousLevel, "previous_level": previousLevel,
"price_coin": level.PriceCoin, "price_coin": level.PriceCoin,
"expires_at_ms": newExpiresAt, "expires_at_ms": newExpiresAt,
"balance_after": coinBalanceAfter, "balance_after": coinBalanceAfter,
"reward_group_id": level.RewardResourceGroupID, "reward_group_id": level.RewardResourceGroupID,
"duration_ms": level.DurationMS, "duration_ms": level.DurationMS,
"required_recharge_coin_amount": level.RequiredRechargeCoinAmount, // 兼容旧账务元数据字段;业务已不再按累计充值限制 VIP 购买,因此固定记录 0。
"required_recharge_coin_amount": int64(0),
} }
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPPurchase, requestHash, orderID, metadata, nowMs); err != nil { if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPPurchase, requestHash, orderID, metadata, nowMs); err != nil {
return ledger.PurchaseVipReceipt{}, err return ledger.PurchaseVipReceipt{}, err

View File

@ -33,11 +33,7 @@ func (r *Repository) ListVipPackages(ctx context.Context, userID int64) (ledger.
if err != nil { if err != nil {
return ledger.UserVip{}, nil, err return ledger.UserVip{}, nil, err
} }
totalRechargeCoin, err := r.userRechargeCoinAmount(ctx, r.db, userID) levels, err := r.listVipLevels(ctx, nowMs, current.Level)
if err != nil {
return ledger.UserVip{}, nil, err
}
levels, err := r.listVipLevels(ctx, nowMs, current.Level, totalRechargeCoin)
return current, levels, err return current, levels, err
} }
@ -76,7 +72,7 @@ func (r *Repository) ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel,
return levels, rows.Err() return levels, rows.Err()
} }
func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLevel int32, totalRechargeCoin int64) ([]ledger.VipLevel, error) { func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLevel int32) ([]ledger.VipLevel, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id,
required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms
@ -96,16 +92,14 @@ func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLeve
if err != nil { if err != nil {
return nil, err return nil, err
} }
level.UserRechargeCoinAmount = totalRechargeCoin // VIP 累充门槛已下线,旧字段只作为兼容输出保留;购买资格只看当前 VIP 等级和配置启停。
level.RechargeGateRequired = level.Level >= 6 level.UserRechargeCoinAmount = 0
level.RechargeGateRequired = false
level.RequiredRechargeCoinAmount = 0
level.CanPurchase = currentLevel == 0 || level.Level >= currentLevel level.CanPurchase = currentLevel == 0 || level.Level >= currentLevel
if !level.CanPurchase { if !level.CanPurchase {
level.PurchaseLockedReason = "current_vip_higher" level.PurchaseLockedReason = "current_vip_higher"
} }
if level.CanPurchase && level.RechargeGateRequired && totalRechargeCoin < level.RequiredRechargeCoinAmount {
level.CanPurchase = false
level.PurchaseLockedReason = "recharge_required"
}
level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, nowMs+level.DurationMS) level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, nowMs+level.DurationMS)
if err != nil { if err != nil {
return nil, err return nil, err
@ -129,26 +123,12 @@ func (r *Repository) scanVipLevel(scanner scanTarget) (ledger.VipLevel, error) {
&level.CreatedAtMS, &level.CreatedAtMS,
&level.UpdatedAtMS, &level.UpdatedAtMS,
) )
level.RechargeGateRequired = level.Level >= 6 // 线上历史行可能还保存 VIP6+ 累充门槛扫描后立即归零保证后台、App 列表和购买事务读到同一个“无门槛”事实。
level.RechargeGateRequired = false
level.RequiredRechargeCoinAmount = 0
return level, err return level, err
} }
func (r *Repository) userRechargeCoinAmount(ctx context.Context, querier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, userID int64) (int64, error) {
var total int64
err := querier.QueryRowContext(ctx, `
SELECT total_coin_amount
FROM wallet_user_recharge_stats
WHERE app_code = ? AND user_id = ?`,
appcode.FromContext(ctx), userID,
).Scan(&total)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return total, err
}
func (r *Repository) ensureUserVipForUpdate(ctx context.Context, tx *sql.Tx, userID int64, nowMs int64) (ledger.UserVip, error) { func (r *Repository) ensureUserVipForUpdate(ctx context.Context, tx *sql.Tx, userID int64, nowMs int64) (ledger.UserVip, error) {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO user_vip_memberships ( INSERT IGNORE INTO user_vip_memberships (

View File

@ -599,7 +599,7 @@ func (r *Repository) SetRechargePolicy(regionID int64, policyVersion string, coi
} }
} }
// SetRechargeStats prepares cumulative recharge stats for VIP gate tests. // SetRechargeStats prepares cumulative recharge stats for recharge-related wallet tests.
func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) { func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) {
r.t.Helper() r.t.Helper()
@ -618,6 +618,21 @@ func (r *Repository) SetRechargeStats(userID int64, totalCoinAmount int64) {
} }
} }
// SetVIPLevelRechargeThreshold writes a legacy VIP threshold directly, so tests can verify old rows no longer affect purchase eligibility.
func (r *Repository) SetVIPLevelRechargeThreshold(level int32, totalCoinAmount int64) {
r.t.Helper()
nowMs := time.Now().UnixMilli()
if _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE vip_levels
SET required_recharge_coin_amount = ?, updated_at_ms = ?
WHERE level = ?`,
totalCoinAmount, nowMs, level,
); err != nil {
r.t.Fatalf("set vip level recharge threshold failed: %v", err)
}
}
// SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests. // SetHostSalaryPolicy seeds the wallet runtime policy snapshot used by salary settlement tests.
func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) { func (r *Repository) SetHostSalaryPolicy(policy ledger.HostSalaryPolicy) {
r.t.Helper() r.t.Helper()