diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index dde51200..384878f1 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -126,8 +126,9 @@ type LuckyGiftMeta struct { Recharge_7DCoins int64 `protobuf:"varint,19,opt,name=recharge_7d_coins,json=recharge7dCoins,proto3" json:"recharge_7d_coins,omitempty"` Recharge_30DCoins int64 `protobuf:"varint,20,opt,name=recharge_30d_coins,json=recharge30dCoins,proto3" json:"recharge_30d_coins,omitempty"` LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"` - // gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的 - // host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。 + // gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益: + // POINT 政策读 host_point_added,旧链路读 host_period_diamond_added;Agency 额外收益不包含在内。 + // 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。 GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"` // user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。 // dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。 diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 1ccec2d1..98730f19 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -40,8 +40,9 @@ message LuckyGiftMeta { int64 recharge_7d_coins = 19; int64 recharge_30d_coins = 20; int64 last_recharged_at_ms = 21; - // gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的 - // host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。 + // gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益: + // POINT 政策读 host_point_added,旧链路读 host_period_diamond_added;Agency 额外收益不包含在内。 + // 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。 int64 gift_income_coins = 22; // user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。 // dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。 diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index a1e8597e..f3833a6c 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -41,6 +41,7 @@ INCREMENTAL_MIGRATION_FILES=( "services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql" "services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" "services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql" + "services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql" ) # Keep MySQL as the only required dependency for this script. Business services diff --git a/server/admin/internal/modules/policyconfig/service.go b/server/admin/internal/modules/policyconfig/service.go index 2dd799ab..b7a2464d 100644 --- a/server/admin/internal/modules/policyconfig/service.go +++ b/server/admin/internal/modules/policyconfig/service.go @@ -26,6 +26,9 @@ const ( policyTaskRewardAssetCoin = "COIN" + agencyShareBaseChargeAmount = "charge_amount" + agencyShareBaseHostIncome = "host_income" + defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1" defaultPolicyTemplateVersion = "v1" ) @@ -64,12 +67,14 @@ type policyInstanceRow struct { } type compiledPolicy struct { - HostPointRatioPPM int64 - AgencyPointRatioPPM int64 - PointsPerUSD int64 - WithdrawFeeBPS int64 - TaskRewardAssetType string - RuleJSON json.RawMessage + HostPointRatioPPM int64 + HostUseGiftTypeRatio bool + AgencyPointRatioPPM int64 + AgencyShareBase string + PointsPerUSD int64 + WithdrawFeeBPS int64 + TaskRewardAssetType string + RuleJSON json.RawMessage } func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, activity activityclient.Client) *Service { @@ -565,7 +570,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error { effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效', host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产', host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm', - agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm', + agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm', points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例', withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps', rule_json JSON NOT NULL COMMENT '完整政策快照', @@ -580,7 +585,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error { return err } // 运行库可能是旧版本建表;幂等补列保证发布新策略时不会因列缺失中断。 - if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) { + if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) { return err } return nil @@ -650,30 +655,76 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) { pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000) host, _ := decoded["host"].(map[string]any) hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70) + // 礼物类型倍率是 wallet 解释完整 rule_json 时使用的开关;Admin 只负责拒绝字符串/数字等歧义配置。 + hostUseGiftTypeRatio, err := optionalBoolFromJSON(host, "use_gift_type_ratio", false) + if err != nil { + return compiledPolicy{}, errors.New("rule_json host use_gift_type_ratio must be boolean") + } agency, _ := decoded["agency"].(map[string]any) // Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0,避免发布动作隐式改变存量 App 账务。 agencyRatioPercent := floatFromJSON(agency["point_ratio_percent"], 0) + // charge_amount 延续旧策略的守恒分账;host_income 是平台在主播实际 POINT 收益之上追加,不能套用二者合计不超过 100% 的限制。 + agencyShareBase, err := agencyShareBaseFromJSON(agency) + if err != nil { + return compiledPolicy{}, err + } withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500) ratioPPM := int64(math.Round(hostRatioPercent * 10000)) agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000)) tasks, _ := decoded["tasks"].(map[string]any) taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin))) - if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || ratioPPM+agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 { + if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 { + return compiledPolicy{}, errors.New("rule_json host point policy is invalid") + } + if agencyShareBase == agencyShareBaseChargeAmount && ratioPPM+agencyRatioPPM > 1000000 { return compiledPolicy{}, errors.New("rule_json host point policy is invalid") } if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" { return compiledPolicy{}, errors.New("rule_json task reward asset_type is invalid") } return compiledPolicy{ - HostPointRatioPPM: ratioPPM, - AgencyPointRatioPPM: agencyRatioPPM, - PointsPerUSD: pointsPerUSD, - WithdrawFeeBPS: withdrawFeeBPS, - TaskRewardAssetType: taskRewardAssetType, - RuleJSON: raw, + HostPointRatioPPM: ratioPPM, + HostUseGiftTypeRatio: hostUseGiftTypeRatio, + AgencyPointRatioPPM: agencyRatioPPM, + AgencyShareBase: agencyShareBase, + PointsPerUSD: pointsPerUSD, + WithdrawFeeBPS: withdrawFeeBPS, + TaskRewardAssetType: taskRewardAssetType, + // 不为编译字段增设运行表列;owner service 必须收到完整规则快照,才能按礼物类型和收益基数解释账务。 + RuleJSON: raw, }, nil } +func agencyShareBaseFromJSON(agency map[string]any) (string, error) { + value, exists := agency["share_base"] + if !exists { + return agencyShareBaseChargeAmount, nil + } + shareBase, ok := value.(string) + if !ok { + return "", errors.New("rule_json agency share_base is invalid") + } + shareBase = strings.ToLower(strings.TrimSpace(shareBase)) + switch shareBase { + case agencyShareBaseChargeAmount, agencyShareBaseHostIncome: + return shareBase, nil + default: + return "", errors.New("rule_json agency share_base is invalid") + } +} + +func optionalBoolFromJSON(object map[string]any, key string, fallback bool) (bool, error) { + value, exists := object[key] + if !exists { + return fallback, nil + } + parsed, ok := value.(bool) + if !ok { + return false, errors.New("JSON value is not boolean") + } + return parsed, nil +} + func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) { var item templateDTO var rule string diff --git a/server/admin/internal/modules/policyconfig/service_test.go b/server/admin/internal/modules/policyconfig/service_test.go index 603c8698..ab2d4383 100644 --- a/server/admin/internal/modules/policyconfig/service_test.go +++ b/server/admin/internal/modules/policyconfig/service_test.go @@ -205,6 +205,9 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin if fami.AgencyPointRatioPPM != 0 { t.Fatalf("legacy policy without agency section must remain zero: %+v", fami) } + if fami.AgencyShareBase != agencyShareBaseChargeAmount || fami.HostUseGiftTypeRatio { + t.Fatalf("legacy policy must keep charge_amount base and disabled gift type ratio: %+v", fami) + } configured, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70},"agency":{"point_ratio_percent":20}}`)) if err != nil { t.Fatalf("compile configured policy failed: %v", err) @@ -212,6 +215,46 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin if configured.AgencyPointRatioPPM != 200000 { t.Fatalf("configured agency ratio mismatch: %+v", configured) } + if configured.AgencyShareBase != agencyShareBaseChargeAmount { + t.Fatalf("missing agency share_base must use legacy charge_amount semantics: %+v", configured) + } +} + +func TestCompilePolicyRejectsChargeAmountOverAllocation(t *testing.T) { + for name, ruleJSON := range map[string]json.RawMessage{ + "legacy default": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20}}`), + "explicit base": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20,"share_base":"charge_amount"}}`), + } { + t.Run(name, func(t *testing.T) { + if _, err := compilePolicy(ruleJSON); err == nil { + t.Fatal("charge_amount policy must reject host and agency allocations above 100 percent") + } + }) + } +} + +func TestCompilePolicyAllowsHostIncomeBonusAndParsesFlags(t *testing.T) { + ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":100,"use_gift_type_ratio":true},"agency":{"point_ratio_percent":20,"share_base":"host_income"}}`) + compiled, err := compilePolicy(ruleJSON) + if err != nil { + t.Fatalf("compile host_income policy failed: %v", err) + } + if compiled.HostPointRatioPPM != 1000000 || compiled.AgencyPointRatioPPM != 200000 { + t.Fatalf("host_income ratios mismatch: %+v", compiled) + } + if compiled.AgencyShareBase != agencyShareBaseHostIncome || !compiled.HostUseGiftTypeRatio { + t.Fatalf("host_income flags mismatch: %+v", compiled) + } + if string(compiled.RuleJSON) != string(ruleJSON) { + t.Fatalf("wallet owner snapshot must keep the complete source rule_json: got %s", compiled.RuleJSON) + } +} + +func TestCompilePolicyRejectsNonBooleanGiftTypeRatioFlag(t *testing.T) { + _, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70,"use_gift_type_ratio":"true"}}`)) + if err == nil { + t.Fatal("use_gift_type_ratio must reject non-boolean JSON values") + } } type fakePolicyActivityClient struct { diff --git a/server/admin/migrations/088_fami_guild_revenue_policy.sql b/server/admin/migrations/088_fami_guild_revenue_policy.sql index 8937fe65..72ae19a8 100644 --- a/server/admin/migrations/088_fami_guild_revenue_policy.sql +++ b/server/admin/migrations/088_fami_guild_revenue_policy.sql @@ -2,30 +2,50 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); --- 收益策略模板是 Lalu/Huwaa/Fami 共用的产品配置入口。存量模板显式补 0,避免旧实例重发时产生新分成。 +-- 收益策略模板是 Lalu/Huwaa/Fami 共用的产品配置入口。默认文档放在 merge 左侧,既能创建 agency 对象, +-- 又不会覆盖已存在的配置;旧实例重发仍保持 0 分成和 charge_amount 守恒口径。 UPDATE admin_policy_templates -SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0), +SET rule_json = JSON_MERGE_PATCH( + CAST('{"agency":{"point_ratio_percent":0,"share_base":"charge_amount"}}' AS JSON), + rule_json + ), updated_at_ms = @now_ms WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1' - AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL; + AND (JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL + OR JSON_EXTRACT(rule_json, '$.agency.share_base') IS NULL); UPDATE admin_policy_template_versions -SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0), +SET rule_json = JSON_MERGE_PATCH( + CAST('{"agency":{"point_ratio_percent":0,"share_base":"charge_amount"}}' AS JSON), + rule_json + ), updated_at_ms = @now_ms WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1' - AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL; + AND (JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL + OR JSON_EXTRACT(rule_json, '$.agency.share_base') IS NULL); --- Fami 从同一份公共政策结构派生独立模板,默认 20%;运营可在 Admin 编辑并发布,不需要改代码。 +-- Fami 的礼物类型先决定主播实际 POINT 收益,再由平台按该收益额外给 Agency owner 20%。内层 JSON_SET +-- 先确保 host/agency 父对象存在,外层再写嵌套字段,避免 MySQL 对不存在父路径静默忽略,同时保留其他规则。 INSERT INTO admin_policy_templates ( template_code, template_version, name, status, rule_json, description, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) SELECT 'fami_guild_revenue_policy', 'v1', 'Fami 公会收益政策', 'active', - JSON_SET(rule_json, '$.agency.point_ratio_percent', 20), - 'Fami 主播与 Agency POINT 收益政策;Agency 默认 20%,以发布后的运行快照为准。', + JSON_SET( + JSON_SET( + rule_json, + '$.host', COALESCE(JSON_EXTRACT(rule_json, '$.host'), JSON_OBJECT()), + '$.agency', COALESCE(JSON_EXTRACT(rule_json, '$.agency'), JSON_OBJECT()) + ), + '$.host.point_ratio_percent', 100, + '$.host.use_gift_type_ratio', JSON_EXTRACT('true', '$'), + '$.agency.point_ratio_percent', 20, + '$.agency.share_base', 'host_income' + ), + 'Fami 主播 POINT 收益政策;Agency 按主播实际 POINT 收益由平台额外发放 20%,以发布后的运行快照为准。', 0, 0, @now_ms, @now_ms FROM admin_policy_templates WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1' diff --git a/server/admin/migrations/115_fami_agency_host_income_share.sql b/server/admin/migrations/115_fami_agency_host_income_share.sql new file mode 100644 index 00000000..04931252 --- /dev/null +++ b/server/admin/migrations/115_fami_agency_host_income_share.sql @@ -0,0 +1,48 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 088 已执行的环境不会重放历史迁移;只覆盖 Fami v1 的四个确定业务字段,保留运营后续补充的提现、任务等规则。 +UPDATE admin_policy_templates +SET rule_json = JSON_SET( + JSON_SET( + rule_json, + '$.host', COALESCE(JSON_EXTRACT(rule_json, '$.host'), JSON_OBJECT()), + '$.agency', COALESCE(JSON_EXTRACT(rule_json, '$.agency'), JSON_OBJECT()) + ), + '$.host.point_ratio_percent', 100, + '$.host.use_gift_type_ratio', JSON_EXTRACT('true', '$'), + '$.agency.point_ratio_percent', 20, + '$.agency.share_base', 'host_income' + ), + description = 'Fami 主播 POINT 收益政策;Agency 按主播实际 POINT 收益由平台额外发放 20%,以发布后的运行快照为准。', + updated_at_ms = @now_ms +WHERE template_code = 'fami_guild_revenue_policy' + AND template_version = 'v1'; + +-- 版本表是发布读取源,必须与模板当前版本同时升级,避免列表展示和实际发布使用不同规则。 +UPDATE admin_policy_template_versions +SET rule_json = JSON_SET( + JSON_SET( + rule_json, + '$.host', COALESCE(JSON_EXTRACT(rule_json, '$.host'), JSON_OBJECT()), + '$.agency', COALESCE(JSON_EXTRACT(rule_json, '$.agency'), JSON_OBJECT()) + ), + '$.host.point_ratio_percent', 100, + '$.host.use_gift_type_ratio', JSON_EXTRACT('true', '$'), + '$.agency.point_ratio_percent', 20, + '$.agency.share_base', 'host_income' + ), + updated_at_ms = @now_ms +WHERE template_code = 'fami_guild_revenue_policy' + AND template_version = 'v1'; + +-- 模板版本被原地修正后,所有引用它的 Fami 实例都必须退回待发布状态;不能只处理默认 instance_code, +-- 否则区域/备用实例会显示 published,却继续运行旧快照。wallet 运行侧仍只由显式发布流程写入。 +UPDATE admin_policy_instances +SET publish_status = 'draft', + publish_error = '', + updated_at_ms = @now_ms +WHERE app_code = 'fami' + AND template_code = 'fami_guild_revenue_policy' + AND template_version = 'v1'; diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index 629403cb..656eb32f 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -213,7 +213,8 @@ type DrawCommand struct { Recharge7DCoins int64 Recharge30DCoins int64 LastRechargedAtMS int64 - // HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。 + // HostPeriodDiamondAdded 是兼容字段名,实际承载 wallet 已落账的主播收益(POINT 或周期钻石); + // Agency 额外收益不进入幸运礼物资金拆分,服务也不能自行重算主播比例。 HostPeriodDiamondAdded int64 // Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。 SenderName string diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index 14f4de02..c817a9cf 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -129,12 +129,25 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(), Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(), LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(), - // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台 - // “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。 - GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(), + // LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 实际给主播 + // 结算的收益:新 POINT 政策读 host_point_added,旧工资钻石链路读 host_period_diamond_added。 + // Agency 额外 20% 不属于主播收益,绝不能混入幸运礼物公共池/平台利润的守恒拆分。 + GiftIncomeCoins: walletHostGiftIncome(targetBilling.Billing), }, nil } +// walletHostGiftIncome 收敛 wallet 两代主播收益字段。两个字段在 wallet 事务里互斥;优先读 POINT +// 让 Fami 收益政策参与幸运礼物资金拆分,回退周期钻石则保持 Lalu 与旧回执的滚动发布兼容。 +func walletHostGiftIncome(billing *walletv1.DebitGiftResponse) int64 { + if billing == nil { + return 0 + } + if billing.GetHostPointAdded() > 0 { + return billing.GetHostPointAdded() + } + return billing.GetHostPeriodDiamondAdded() +} + func normalizeLuckyGiftStrategyVersion(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/services/room-service/internal/room/service/lucky_gift_test.go b/services/room-service/internal/room/service/lucky_gift_test.go index 1f5d4283..266066e4 100644 --- a/services/room-service/internal/room/service/lucky_gift_test.go +++ b/services/room-service/internal/room/service/lucky_gift_test.go @@ -127,13 +127,15 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "super_lucky", + HostPointAdded: 7, }, { - BillingReceiptId: "receipt-lucky-legacy-token", - CoinSpent: 100, - GiftPointAdded: 100, - HeatValue: 100, - GiftTypeCode: "super_lucky", + BillingReceiptId: "receipt-lucky-legacy-token", + CoinSpent: 100, + GiftPointAdded: 100, + HeatValue: 100, + GiftTypeCode: "super_lucky", + HostPeriodDiamondAdded: 1, }, }} luckyGift := &luckyGiftTestClient{} @@ -194,6 +196,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { drawMeta.GetCoinSpent() != 100 || drawMeta.GetDeviceId() != "device-auth-session-1" || drawMeta.GetAnchorId() != "101" || + drawMeta.GetGiftIncomeCoins() != 7 || drawMeta.GetVisibleRegionId() != 9001 || drawMeta.GetSenderName() != "lucky sender" || drawMeta.GetSenderAvatar() != "https://cdn.example.com/sender.png" || @@ -218,6 +221,9 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { if legacyDraw.GetDeviceId() != "" { t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId()) } + if legacyDraw.GetGiftIncomeCoins() != 1 { + t.Fatalf("legacy host-period income must remain the lucky fund split owner fact: %+v", legacyDraw) + } luckyGift.checkStrategy = "dynamic_v3" debitsBeforeDynamicReject := len(wallet.debitRequests) diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 09a79f3c..96c6bcd7 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -181,7 +181,7 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances ( effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效', host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产', host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm', - agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm', + agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm', points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例', withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps', rule_json JSON NOT NULL COMMENT '完整政策快照', @@ -220,7 +220,9 @@ CREATE TABLE IF NOT EXISTS agency_point_share_entries ( sender_user_id BIGINT NOT NULL COMMENT '送礼用户 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID,私聊送礼为空', point_delta BIGINT NOT NULL COMMENT 'Agency owner 本次 POINT 分成', - ratio_ppm BIGINT NOT NULL COMMENT 'Agency 分成比例快照', + ratio_ppm BIGINT NOT NULL COMMENT 'Agency 分成比例快照,计算基数见 share_base_type', + share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数:unknown/charge_amount/host_income;unknown 表示旧写入方未提供快照', + share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数;share_base_type=unknown 时不可解释为真实 0', policy_instance_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '收益策略实例快照', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', PRIMARY KEY (app_code, transaction_id, agency_owner_user_id), diff --git a/services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql b/services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql new file mode 100644 index 00000000..96fcfda4 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +SET time_zone = '+00:00'; + +USE hyapp_wallet; + +-- Agency 分成表通常远小于 wallet_entries,但仍属于不可重建的账务审计事实。先补兼容列,再发布 +-- 新二进制;滚动期旧写入方没有公式基数,必须标记 unknown,不能伪装成 charge_amount=0。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_point_share_entries' + AND COLUMN_NAME = 'share_base_type') = 0, + 'ALTER TABLE agency_point_share_entries ADD COLUMN share_base_type VARCHAR(32) NOT NULL DEFAULT ''unknown'' COMMENT ''分成基数:unknown/charge_amount/host_income'' AFTER ratio_ppm, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agency_point_share_entries' + AND COLUMN_NAME = 'share_base_amount') = 0, + 'ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''本次分成计算基数;share_base_type=unknown 时数值不可解释'' AFTER share_base_type, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 不在 DDL migration 中改 active wallet_policy_instances。旧二进制不理解 gift-type ratio 和 +-- host_income,若先改运行快照会在滚动窗口把 lucky/super-lucky 错发成 100%/20%,self-owner 还会 +-- 因重复余额事件回滚。Admin 115 把 Fami 实例置 draft;新 wallet 全部就绪后由显式 PublishInstance +-- 原子重建运行快照,保持 Admin 控制面状态与 wallet 实际策略一致。 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index d0405dda..8854630f 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -171,40 +171,340 @@ func TestHuwaaHostGiftPolicyCreditsPointWithoutCoinIncomeAndAllowsSelfGift(t *te func TestFamiHostGiftPolicyCreditsAgencyShareWithHistoricalOwnershipSnapshot(t *testing.T) { repository := mysqltest.NewRepository(t) - repository.SetBalanceForApp("fami", 32001, 1000) - repository.SetGiftPriceForApp("fami", "fami-rose", "v1", 100, 0, 100) - repository.SeedWalletPolicyInstanceWithAgency("fami", "fami-guild-live", 0, "active", 700000, 200000) + // Fami 公会收益先按礼物类型计算主播实收,再额外给 Agency owner 主播实收的 20%; + // 该公式允许一次礼物产生 100% 主播收益和额外 20% 公会收益,不能套用旧的总资金池切分语义。 + repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") + svc := walletservice.New(repository) + + tests := []struct { + name string + giftType string + giftID string + commandID string + senderID int64 + hostID int64 + agencyID int64 + wantHost int64 + wantAgency int64 + wantRatio string + wantPPM int64 + }{ + {name: "normal", giftType: resourcedomain.GiftTypeNormal, giftID: "fami-normal-rose", commandID: "cmd-fami-agency-normal", senderID: 32001, hostID: 32002, agencyID: 32003, wantHost: 10_000, wantAgency: 2_000, wantRatio: "100.00", wantPPM: 1_000_000}, + {name: "lucky", giftType: resourcedomain.GiftTypeLucky, giftID: "fami-lucky-rose", commandID: "cmd-fami-agency-lucky", senderID: 32011, hostID: 32012, agencyID: 32013, wantHost: 1_000, wantAgency: 200, wantRatio: "10.00", wantPPM: 100_000}, + {name: "super_lucky", giftType: resourcedomain.GiftTypeSuperLucky, giftID: "fami-super-lucky-rose", commandID: "cmd-fami-agency-super-lucky", senderID: 32021, hostID: 32022, agencyID: 32023, wantHost: 100, wantAgency: 20, wantRatio: "1.00", wantPPM: 10_000}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository.SetBalanceForApp("fami", test.senderID, 10_000) + repository.SetGiftPriceForApp("fami", test.giftID, "v1", 10_000, 0, 10_000) + repository.SetGiftType(test.giftID, test.giftType) + // 区域值刻意与全局 fallback 区分,验证 POINT 政策把真正命中的第一阶段倍率来源一并冻结。 + repository.SetGiftDiamondRatioForApp("fami", 12, test.giftType, test.wantRatio) + command := ledger.DebitGiftCommand{ + AppCode: "fami", CommandID: test.commandID, RoomID: "room-fami", + SenderUserID: test.senderID, TargetUserID: test.hostID, TargetIsHost: true, TargetHostRegionID: 12, + TargetAgencyOwnerUserID: test.agencyID, GiftID: test.giftID, GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + } + + receipt, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("DebitGift with Fami %s agency policy failed: %v", test.giftType, err) + } + if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != test.wantHost || receipt.HostPointBalanceAfter != test.wantHost || + receipt.HostPointAssetType != ledger.AssetPoint || receipt.HostPointPolicyInstanceCode != "fami-guild-live" { + t.Fatalf("Fami %s host point receipt mismatch: %+v", test.giftType, receipt) + } + assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost) + assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency) + + // 流水必须冻结送礼时的主播与 owner 归属、公式名称和实际主播收入;以后成员转会不能反推改写历史收益。 + shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?" + shareArgs := []any{receipt.TransactionID, test.hostID, test.agencyID, test.wantAgency, int64(200_000), "host_income", test.wantHost} + if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { + t.Fatalf("Fami %s agency ownership/formula snapshot mismatch, got %d", test.giftType, got) + } + // POINT 政策也必须冻结第一阶段礼物倍率;只记录最终 host_income 无法解释未来配置变更前的计算过程。 + if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12", receipt.TransactionID, test.wantRatio, test.wantPPM); got != 1 { + t.Fatalf("Fami %s gift-type ratio transaction snapshot mismatch, got %d", test.giftType, got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { + t.Fatalf("Fami %s host credit must publish one role fact, got %d", test.giftType, got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ?", receipt.TransactionID, "HostPointCredited", test.wantRatio, test.wantPPM); got != 1 { + t.Fatalf("Fami %s gift-type ratio role fact mismatch, got %d", test.giftType, got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 { + t.Fatalf("Fami %s agency share must publish one role fact, got %d", test.giftType, got) + } + + // 同一命令重放只读首次交易快照,不重新计算礼物类型比例,也不重复写 owner 收益与角色事实。 + replayed, err := svc.DebitGift(context.Background(), command) + if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointAdded != receipt.HostPointAdded { + t.Fatalf("Fami %s agency share replay mismatch: receipt=%+v err=%v", test.giftType, replayed, err) + } + assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost) + assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency) + if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { + t.Fatalf("Fami %s replay duplicated or changed owner snapshot, got %d", test.giftType, got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", receipt.TransactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 { + t.Fatalf("Fami %s replay duplicated role facts, got %d", test.giftType, got) + } + }) + } +} + +// TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact 验证公会长本人收礼时仍保留主播和 Agency 两套账务角色, +// 但同一 POINT 账户只能发布一条合并后的余额事实,避免下游按两条 balance event 覆盖最终余额。 +func TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp("fami", 33001, 10_000) + repository.SetGiftPriceForApp("fami", "fami-owner-self-normal", "v1", 10_000, 0, 10_000) + repository.SetGiftType("fami-owner-self-normal", resourcedomain.GiftTypeNormal) + repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") + svc := walletservice.New(repository) + command := ledger.DebitGiftCommand{ + AppCode: "fami", CommandID: "cmd-fami-owner-self-direct", DirectGift: true, + SenderUserID: 33001, TargetUserID: 33002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 33002, + GiftID: "fami-owner-self-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + } + + receipt, err := svc.DebitGift(context.Background(), command) + if err != nil { + t.Fatalf("DebitGift with self-owned Fami agency failed: %v", err) + } + if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != 10_000 || receipt.HostPointBalanceAfter != 12_000 { + t.Fatalf("self-owned Fami receipt mismatch: %+v", receipt) + } + assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000) + if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND status = ?", receipt.TransactionID, "direct_gift_debit", "succeeded"); got != 1 { + t.Fatalf("self-owned direct gift transaction must succeed exactly once, got %d", got) + } + + // 主播 10000 与 Agency 2000 是两条可审计分录;账户级余额事实则聚合成 12000,只允许一条。 + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 { + t.Fatalf("self-owned gift must preserve two role ledger entries, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 { + t.Fatalf("self-owned POINT account must publish one merged balance fact, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint, int64(12_000)); got != 1 { + t.Fatalf("self-owned POINT balance fact must carry merged +12000 delta, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 { + t.Fatalf("self-owned gift must retain one HostPointCredited role fact, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 { + t.Fatalf("self-owned gift must retain one AgencyPointCredited role fact, got %d", got) + } + shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?" + shareArgs := []any{receipt.TransactionID, int64(33002), int64(33002), int64(2_000), int64(200_000), "host_income", int64(10_000)} + if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { + t.Fatalf("self-owned agency share snapshot mismatch, got %d", got) + } + + replayed, err := svc.DebitGift(context.Background(), command) + if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointBalanceAfter != 12_000 { + t.Fatalf("self-owned Fami gift replay mismatch: receipt=%+v err=%v", replayed, err) + } + assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000) + if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 { + t.Fatalf("self-owned replay duplicated role entries, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 { + t.Fatalf("self-owned replay duplicated merged balance fact, got %d", got) + } + if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 { + t.Fatalf("self-owned replay duplicated share row, got %d", got) + } +} + +// TestFamiBatchGiftKeepsIndependentAgencyRoleFacts 验证批量事务按 target 独立固化主播/Agency 角色: +// 一个 target 是 owner 本人,另一个是旗下主播,两者共享 owner 账户也不能互相覆盖或重复投递。 +func TestFamiBatchGiftKeepsIndependentAgencyRoleFacts(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp("fami", 34001, 20_000) + repository.SetGiftPriceForApp("fami", "fami-batch-normal", "v1", 10_000, 0, 10_000) + repository.SetGiftType("fami-batch-normal", resourcedomain.GiftTypeNormal) + repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income") + svc := walletservice.New(repository) + command := ledger.BatchDebitGiftCommand{ + AppCode: "fami", CommandID: "cmd-fami-agency-batch", RoomID: "room-fami-batch", + SenderUserID: 34001, GiftID: "fami-batch-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + Targets: []ledger.DebitGiftTargetCommand{ + {CommandID: "cmd-fami-agency-batch:target:34002", TargetUserID: 34002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002}, + {CommandID: "cmd-fami-agency-batch:target:34003", TargetUserID: 34003, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002}, + }, + } + + receipt, err := svc.BatchDebitGift(context.Background(), command) + if err != nil { + t.Fatalf("BatchDebitGift with mixed Fami agency targets failed: %v", err) + } + if len(receipt.Targets) != 2 || receipt.Aggregate.CoinSpent != 20_000 || receipt.Aggregate.HostPointAdded != 20_000 { + t.Fatalf("mixed Fami agency batch aggregate mismatch: %+v", receipt) + } + assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0) + assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000) + assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000) + + receiptsByTarget := make(map[int64]ledger.Receipt, len(receipt.Targets)) + for _, target := range receipt.Targets { + receiptsByTarget[target.TargetUserID] = target.Receipt + } + selfReceipt, selfOK := receiptsByTarget[34002] + memberReceipt, memberOK := receiptsByTarget[34003] + if !selfOK || !memberOK || selfReceipt.TransactionID == "" || memberReceipt.TransactionID == "" { + t.Fatalf("batch must return one stable receipt per target: %+v", receipt.Targets) + } + if selfReceipt.HostPointBalanceAfter != 12_000 { + t.Fatalf("batch self-owner receipt must expose final shared POINT balance: %+v", selfReceipt) + } + for _, targetReceipt := range []struct { + name string + hostID int64 + receipt ledger.Receipt + }{ + {name: "self_owner", hostID: 34002, receipt: selfReceipt}, + {name: "member", hostID: 34003, receipt: memberReceipt}, + } { + if targetReceipt.receipt.HostPointAdded != 10_000 { + t.Fatalf("%s target host credit mismatch: %+v", targetReceipt.name, targetReceipt.receipt) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "HostPointCredited"); got != 1 { + t.Fatalf("%s target must publish one host role fact, got %d", targetReceipt.name, got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "AgencyPointCredited"); got != 1 { + t.Fatalf("%s target must publish one agency role fact, got %d", targetReceipt.name, got) + } + if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", targetReceipt.receipt.TransactionID, targetReceipt.hostID, int64(34002), int64(2_000), "host_income", int64(10_000)); got != 1 { + t.Fatalf("%s target agency snapshot mismatch, got %d", targetReceipt.name, got) + } + } + + // Self target 的两种角色落在同一账户,只能产生 +12000 一条余额事实;成员 target 则分别通知成员与 owner。 + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", selfReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 1 { + t.Fatalf("batch self target must publish one merged POINT balance fact, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", selfReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(12_000)); got != 1 { + t.Fatalf("batch self target merged POINT delta mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", memberReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 2 { + t.Fatalf("batch member target must publish separate host/owner balance facts, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34003), ledger.AssetPoint, int64(10_000)); got != 1 { + t.Fatalf("batch member host POINT balance fact mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(2_000)); got != 1 { + t.Fatalf("batch member agency POINT balance fact mismatch, got %d", got) + } + + // 批量重放按两个 child command 的原交易返回,不重复扣 sender、不重复增加共享 owner,也不重发任一 target 的角色事实。 + replayed, err := svc.BatchDebitGift(context.Background(), command) + if err != nil { + t.Fatalf("mixed Fami agency batch replay failed: %v", err) + } + if len(replayed.Targets) != 2 || replayed.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || replayed.Targets[1].Receipt.TransactionID != receipt.Targets[1].Receipt.TransactionID || replayed.Targets[0].Receipt.HostPointBalanceAfter != 12_000 { + t.Fatalf("mixed Fami agency batch replay transaction mismatch: first=%+v replay=%+v", receipt.Targets, replayed.Targets) + } + assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0) + assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000) + assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000) + if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", command.Targets[0].CommandID, command.Targets[1].CommandID); got != 2 { + t.Fatalf("mixed Fami agency batch replay duplicated child transactions, got %d", got) + } + if got := repository.CountRows("agency_point_share_entries", "transaction_id IN (?, ?)", selfReceipt.TransactionID, memberReceipt.TransactionID); got != 2 { + t.Fatalf("mixed Fami agency batch replay duplicated share rows, got %d", got) + } + for _, transactionID := range []string{selfReceipt.TransactionID, memberReceipt.TransactionID} { + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", transactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 { + t.Fatalf("mixed Fami agency batch replay duplicated role facts for transaction %s: got %d", transactionID, got) + } + } +} + +// TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero 固化旧 charge_amount 策略的极小额边界: +// host 与 Agency 各自向下取整,Agency 为正数时必须独立锁账户,不能依赖 host 也大于 0。 +func TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp("fami", 35001, 2) + repository.SetGiftPriceForApp("fami", "fami-batch-agency-rounding", "v1", 2, 0, 2) + repository.SeedWalletGiftRevenuePolicy("fami", "fami-legacy-rounding", 0, "active", 1, 999_999, false, "charge_amount") + svc := walletservice.New(repository) + command := ledger.BatchDebitGiftCommand{ + AppCode: "fami", CommandID: "cmd-fami-batch-agency-rounding", RoomID: "room-fami-rounding", + SenderUserID: 35001, GiftID: "fami-batch-agency-rounding", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + Targets: []ledger.DebitGiftTargetCommand{{ + CommandID: "cmd-fami-batch-agency-rounding:target:35002", TargetUserID: 35002, + TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 35003, + }}, + } + + receipt, err := svc.BatchDebitGift(context.Background(), command) + if err != nil { + t.Fatalf("BatchDebitGift agency-only rounding failed: %v", err) + } + if len(receipt.Targets) != 1 || receipt.Targets[0].Receipt.HostPointAdded != 0 { + t.Fatalf("agency-only rounding receipt mismatch: %+v", receipt) + } + assertBalanceForApp(t, svc, "fami", 35002, ledger.AssetPoint, 0) + assertBalanceForApp(t, svc, "fami", 35003, ledger.AssetPoint, 1) + transactionID := receipt.Targets[0].Receipt.TransactionID + if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", transactionID, int64(1), "charge_amount", int64(2)); got != 1 { + t.Fatalf("agency-only rounding audit snapshot mismatch, got %d", got) + } + if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND available_delta = ?", transactionID, "AgencyPointCredited", int64(35003), int64(1)); got != 1 { + t.Fatalf("agency-only rounding role fact mismatch, got %d", got) + } +} + +// TestGiftRevenuePolicyIsReusableAcrossApps 证明收益模块不依赖 Fami 分支: +// 任意 App 只要发布同一策略和区域礼物倍率,Direct/Batch 都应复用同一结算实现。 +func TestGiftRevenuePolicyIsReusableAcrossApps(t *testing.T) { + const appCode = "next-app" + repository := mysqltest.NewRepository(t) + repository.SetBalanceForApp(appCode, 36001, 10_000) + repository.SetGiftPriceForApp(appCode, "next-app-lucky", "v1", 10_000, 0, 10_000) + repository.SetGiftType("next-app-lucky", resourcedomain.GiftTypeLucky) + repository.SetGiftDiamondRatioForApp(appCode, 12, resourcedomain.GiftTypeLucky, "25.00") + repository.SeedWalletGiftRevenuePolicy(appCode, "shared-agency-host-income", 12, "active", 1_000_000, 200_000, true, "host_income") svc := walletservice.New(repository) receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami", - SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12, - TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + AppCode: appCode, CommandID: "cmd-next-app-agency-lucky", RoomID: "room-next-app", + SenderUserID: 36001, TargetUserID: 36002, TargetIsHost: true, + TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36003, + GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, }) if err != nil { - t.Fatalf("DebitGift with Fami agency policy failed: %v", err) + t.Fatalf("cross-app gift revenue policy failed: %v", err) } - if receipt.HostPointAdded != 70 || receipt.HostPointBalanceAfter != 70 { - t.Fatalf("Fami host point receipt mismatch: %+v", receipt) + if receipt.HostPointAdded != 2_500 || receipt.HostPointBalanceAfter != 2_500 || receipt.HostPointPolicyInstanceCode != "shared-agency-host-income" { + t.Fatalf("cross-app host revenue mismatch: %+v", receipt) } - assertBalanceForApp(t, svc, "fami", 32002, ledger.AssetPoint, 70) - assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20) - if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ?", receipt.TransactionID, int64(32002), int64(32003), int64(20), int64(200000)); got != 1 { - t.Fatalf("Fami agency share snapshot mismatch, got %d", got) - } - if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 { - t.Fatalf("Fami agency share must publish one event, got %d", got) + assertBalanceForApp(t, svc, appCode, 36002, ledger.AssetPoint, 2_500) + assertBalanceForApp(t, svc, appCode, 36003, ledger.AssetPoint, 500) + if got := repository.CountRows("wallet_transactions", "app_code = ? AND transaction_id = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 250000", appCode, receipt.TransactionID); got != 1 { + t.Fatalf("cross-app effective-region ratio snapshot mismatch, got %d", got) } - replayed, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{ - AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami", - SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12, - TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + repository.SetBalanceForApp(appCode, 36011, 10_000) + batch, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{ + AppCode: appCode, CommandID: "cmd-next-app-agency-lucky-batch", RoomID: "room-next-app", + SenderUserID: 36011, GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12, + Targets: []ledger.DebitGiftTargetCommand{{ + CommandID: "cmd-next-app-agency-lucky-batch:target:36012", TargetUserID: 36012, + TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36013, + }}, }) - if err != nil || replayed.TransactionID != receipt.TransactionID { - t.Fatalf("Fami agency share replay mismatch: receipt=%+v err=%v", replayed, err) + if err != nil { + t.Fatalf("cross-app batch gift revenue policy failed: %v", err) } - assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20) + if len(batch.Targets) != 1 || batch.Targets[0].Receipt.HostPointAdded != 2_500 { + t.Fatalf("cross-app batch host revenue mismatch: %+v", batch) + } + assertBalanceForApp(t, svc, appCode, 36012, ledger.AssetPoint, 2_500) + assertBalanceForApp(t, svc, appCode, 36013, ledger.AssetPoint, 500) } func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) { diff --git a/services/wallet-service/internal/storage/mysql/agency_point_share.go b/services/wallet-service/internal/storage/mysql/agency_point_share.go index 4af3beea..6a77166f 100644 --- a/services/wallet-service/internal/storage/mysql/agency_point_share.go +++ b/services/wallet-service/internal/storage/mysql/agency_point_share.go @@ -16,10 +16,12 @@ func (r *Repository) insertAgencyPointShare(ctx context.Context, tx *sql.Tx, tra _, err := tx.ExecContext(ctx, ` INSERT INTO agency_point_share_entries ( app_code, transaction_id, host_user_id, agency_owner_user_id, sender_user_id, - room_id, point_delta, ratio_ppm, policy_instance_code, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + room_id, point_delta, ratio_ppm, share_base_type, share_base_amount, + policy_instance_code, created_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID, metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM, + metadata.AgencyPointShareBase, metadata.AgencyPointShareBaseAmount, metadata.HostPointPolicyInstanceCode, nowMS, ) return err diff --git a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go index 74ecfa26..39d5e618 100644 --- a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go @@ -118,13 +118,37 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb return ledger.BatchGiftReceipt{}, err } + type resolvedHostDiamondRatio struct { + ratio giftDiamondRatioSnapshot + effectiveRegionID int64 + } + // 同一批目标可能跨主播区域。POINT 新政策与旧周期钻石都必须消费相同的 owner 比例解析器; + // 以 region_id 缓存可避免为每个 target 重复查询,同时保留区域缺失时回退全局配置的历史行为。 + hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio) + resolveHostRatio := func(regionID int64) (resolvedHostDiamondRatio, error) { + if resolved, exists := hostRatiosByRegion[regionID]; exists { + return resolved, nil + } + ratio, effectiveRegionID, resolveErr := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, regionID, giftConfig.GiftTypeCode) + if resolveErr != nil { + return resolvedHostDiamondRatio{}, resolveErr + } + resolved := resolvedHostDiamondRatio{ratio: ratio, effectiveRegionID: effectiveRegionID} + hostRatiosByRegion[regionID] = resolved + return resolved, nil + } + type targetHostPointPolicy struct { - policy walletPolicyInstance - amount int64 - userID int64 - agencyAmount int64 - agencyOwnerUserID int64 - active bool + policy walletPolicyInstance + amount int64 + userID int64 + giftRatioPercent string + giftRatioPPM int64 + giftRatioRegionID int64 + agencyAmount int64 + agencyShareBaseAmount int64 + agencyOwnerUserID int64 + active bool } hostPointPolicies := make(map[string]targetHostPointPolicy, len(command.Targets)) if chargeSource != giftChargeSourceBag { @@ -143,25 +167,43 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb if !active { continue } - // 批量礼物逐目标判断主播收益政策;自送礼不排除,背包礼物已在外层过滤。 - amount, err := giftDiamondAmount(chargeAmount, policy.HostPointRatioPPM) - if err != nil { - return ledger.BatchGiftReceipt{}, err - } - agencyAmount := int64(0) - if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 { - agencyAmount, err = giftDiamondAmount(chargeAmount, policy.AgencyPointRatioPPM) + // 批量目标逐一按其政策和主播区域计算;同一礼物不能拿第一个主播的区域或收益覆盖后续目标。 + hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} + giftRatioPercent := "0.00" + giftRatioPPM := int64(0) + giftRatioRegionID := int64(0) + if policy.HostGiftTypeRatioEnabled { + resolvedRatio, err := resolveHostRatio(target.TargetHostRegionID) if err != nil { return ledger.BatchGiftReceipt{}, err } + hostGiftTypeRatio = resolvedRatio.ratio + giftRatioPercent = resolvedRatio.ratio.Percent + giftRatioPPM = resolvedRatio.ratio.PPM + giftRatioRegionID = resolvedRatio.effectiveRegionID + } + pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, policy) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + amount := pointAmounts.HostPointAdded + agencyAmount := int64(0) + agencyShareBaseAmount := int64(0) + if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 { + agencyAmount = pointAmounts.AgencyPointAdded + agencyShareBaseAmount = pointAmounts.AgencyShareBaseAmount } hostPointPolicies[target.CommandID] = targetHostPointPolicy{ - policy: policy, - amount: amount, - userID: target.TargetUserID, - agencyAmount: agencyAmount, - agencyOwnerUserID: target.TargetAgencyOwnerUserID, - active: true, + policy: policy, + amount: amount, + userID: target.TargetUserID, + giftRatioPercent: giftRatioPercent, + giftRatioPPM: giftRatioPPM, + giftRatioRegionID: giftRatioRegionID, + agencyAmount: agencyAmount, + agencyShareBaseAmount: agencyShareBaseAmount, + agencyOwnerUserID: target.TargetAgencyOwnerUserID, + active: true, } } } @@ -185,15 +227,16 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb } } for _, hostPointPolicy := range hostPointPolicies { - if hostPointPolicy.amount <= 0 { - continue + if hostPointPolicy.amount > 0 { + lockRequests = append(lockRequests, walletAccountLockRequest{ + UserID: hostPointPolicy.userID, + AssetType: ledger.AssetPoint, + CreateIfMissing: true, + }) } - lockRequests = append(lockRequests, walletAccountLockRequest{ - UserID: hostPointPolicy.userID, - AssetType: ledger.AssetPoint, - CreateIfMissing: true, - }) if hostPointPolicy.agencyAmount > 0 { + // charge_amount 兼容策略可能因两种比例不同而出现 host 向下取整为 0、Agency 仍为正数; + // Agency 账户锁不能被 host amount 的判断包住,否则 Direct 成功而 Batch 返回内部错误。 lockRequests = append(lockRequests, walletAccountLockRequest{ UserID: hostPointPolicy.agencyOwnerUserID, AssetType: ledger.AssetPoint, @@ -233,28 +276,15 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb events = append(events, resourceEvent) } - type resolvedHostDiamondRatio struct { - ratio giftDiamondRatioSnapshot - effectiveRegionID int64 - } // 批量送礼中的主播可能属于不同区域,必须逐区域解析周期钻石比例;同一区域只查一次, // 解析器会在区域配置缺失时回退 region_id=0,既避免 N 次重复查询,也不混用其他主播区域。 - hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio) for _, target := range command.Targets { if !target.TargetIsHost || hostPointPolicies[target.CommandID].active { continue } - if _, exists := hostRatiosByRegion[target.TargetHostRegionID]; exists { - continue - } - ratio, effectiveRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, target.TargetHostRegionID, giftConfig.GiftTypeCode) - if err != nil { + if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil { return ledger.BatchGiftReceipt{}, err } - hostRatiosByRegion[target.TargetHostRegionID] = resolvedHostDiamondRatio{ - ratio: ratio, - effectiveRegionID: effectiveRegionID, - } } targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets)) @@ -262,8 +292,12 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb hostPointPolicy := hostPointPolicies[target.CommandID] hostPeriodDiamondAdded := int64(0) hostPeriodCycleKey := "" - giftDiamondRatioPercent := "0.00" - giftDiamondRatioRegionID := int64(0) + giftDiamondRatioPercent := hostPointPolicy.giftRatioPercent + if giftDiamondRatioPercent == "" { + giftDiamondRatioPercent = "0.00" + } + giftDiamondRatioPPM := hostPointPolicy.giftRatioPPM + giftDiamondRatioRegionID := hostPointPolicy.giftRatioRegionID if target.TargetIsHost && !hostPointPolicy.active { resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID] if !exists { @@ -274,6 +308,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb return ledger.BatchGiftReceipt{}, err } giftDiamondRatioPercent = resolvedRatio.ratio.Percent + giftDiamondRatioPPM = resolvedRatio.ratio.PPM giftDiamondRatioRegionID = resolvedRatio.effectiveRegionID hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) } @@ -373,6 +408,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID, HostPeriodDiamondAdded: hostPeriodDiamondAdded, GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioPPM: giftDiamondRatioPPM, GiftDiamondRatioRegionID: giftDiamondRatioRegionID, HostPeriodCycleKey: hostPeriodCycleKey, HostPointAssetType: hostPointPolicy.policy.HostPointAsset, @@ -385,6 +421,8 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb AgencyPointAdded: hostPointPolicy.agencyAmount, AgencyPointBalanceAfter: agencyPointBalanceAfter, AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM, + AgencyPointShareBase: hostPointPolicy.policy.AgencyPointShareBase, + AgencyPointShareBaseAmount: hostPointPolicy.agencyShareBaseAmount, PointsPerUSD: hostPointPolicy.policy.PointsPerUSD, RoomID: command.RoomID, RoomRegionID: command.RegionID, @@ -473,6 +511,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb return ledger.BatchGiftReceipt{}, err } metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount + if agencyPointState == targetPointState { + // 同一 child transaction 的两个角色共用账户时,回执和主播角色事实都以第二条 + // 分录后的最终 POINT 余额为准,不能暴露本批次内部的中间余额。 + metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount + } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: hostPointPolicy.agencyOwnerUserID, @@ -521,12 +564,28 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb if targetGiftIncomeCoinAmount > 0 { events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) } + sharedHostAgencyPointAccount := hostPointPolicy.amount > 0 && hostPointPolicy.agencyAmount > 0 && target.TargetUserID == hostPointPolicy.agencyOwnerUserID + sharedPointDelta := int64(0) + if sharedHostAgencyPointAccount { + sharedPointDelta, err = checkedAdd(hostPointPolicy.amount, hostPointPolicy.agencyAmount) + if err != nil { + return ledger.BatchGiftReceipt{}, err + } + } if hostPointPolicy.amount > 0 { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + if sharedHostAgencyPointAccount { + // 单个 batch target 仍是一笔独立 wallet transaction。owner=self 时只聚合该 target 的 + // 通用余额事件,不跨 target 合并;这样每个子 command 的重放与下游游标仍保持独立。 + events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + } else { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + } events = append(events, hostPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) } if hostPointPolicy.agencyAmount > 0 { - events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + if !sharedHostAgencyPointAccount { + events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit)) + } events = append(events, agencyPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs)) } if hostPeriodDiamondAdded > 0 { diff --git a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go index 2536c375..8ba8620e 100644 --- a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go @@ -95,8 +95,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } } + // 礼物类型倍率既参与旧周期钻石,也可能参与新 POINT 政策。三项快照在事务内一次解析并落 metadata, + // 后续运营调整区域倍率时,历史流水仍能证明 charge_amount 如何得到主播实际收益。 + giftDiamondRatioPercent := "0.00" + giftDiamondRatioPPM := int64(0) + giftDiamondRatioRegionID := int64(0) hostPointAdded := int64(0) agencyPointAdded := int64(0) + agencyPointShareBaseAmount := int64(0) hostPointPolicy := walletPolicyInstance{} hostPointPolicyActive := false hostPointRegionID := command.TargetHostRegionID @@ -109,26 +115,34 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } if hostPointPolicyActive { - // Huwaa 第一套政策把主播有效付费礼物收益写入 POINT;自送礼不被排除,背包/机器人礼物不属于有效付费礼物。 - hostPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.HostPointRatioPPM) - if err != nil { - return ledger.Receipt{}, err - } - // 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。 - giftIncomeCoinAmount = 0 - if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 { - // Agency 分成只跟随服务端注入的送礼时归属快照,客户端不能指定;自送礼同样属于有效付费礼物。 - agencyPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.AgencyPointRatioPPM) + // 旧政策直接以 charge_amount 为主播基数;Fami 新政策显式开启 gift-type ratio 后, + // 必须先按主播所在区域和礼物类型得到原有钻石收益,再换算 POINT。两种模式都由运行快照决定, + // 不能按 app_code 写死,避免未来新 App 复制代码分支。 + hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000} + if hostPointPolicy.HostGiftTypeRatioEnabled { + hostGiftTypeRatio, giftDiamondRatioRegionID, err = r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode) if err != nil { return ledger.Receipt{}, err } + giftDiamondRatioPercent = hostGiftTypeRatio.Percent + giftDiamondRatioPPM = hostGiftTypeRatio.PPM + } + pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, hostPointPolicy) + if err != nil { + return ledger.Receipt{}, err + } + hostPointAdded = pointAmounts.HostPointAdded + // 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。 + giftIncomeCoinAmount = 0 + if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 { + // Agency 资格只跟随 gateway 注入并进入 wallet 幂等哈希的归属快照;用户不能自报 owner。 + agencyPointAdded = pointAmounts.AgencyPointAdded + agencyPointShareBaseAmount = pointAmounts.AgencyShareBaseAmount } } } hostPeriodDiamondAdded := int64(0) hostPeriodCycleKey := "" - giftDiamondRatioPercent := "0.00" - giftDiamondRatioRegionID := int64(0) if command.TargetIsHost && !command.RobotGift && !hostPointPolicyActive { // 主播周期钻石按主播身份所属区域结算,而不是房间可见区域或送礼人区域; // 区域未配置时由解析器回退 region_id=0,保证历史全局配置继续生效。 @@ -142,6 +156,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } giftDiamondRatioPercent = ratio.Percent + giftDiamondRatioPPM = ratio.PPM giftDiamondRatioRegionID = ratioRegionID hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs) } @@ -297,6 +312,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID, HostPeriodDiamondAdded: hostPeriodDiamondAdded, GiftDiamondRatioPercent: giftDiamondRatioPercent, + GiftDiamondRatioPPM: giftDiamondRatioPPM, GiftDiamondRatioRegionID: giftDiamondRatioRegionID, HostPeriodCycleKey: hostPeriodCycleKey, HostPointAssetType: hostPointPolicy.HostPointAsset, @@ -309,6 +325,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm AgencyPointAdded: agencyPointAdded, AgencyPointBalanceAfter: agencyPointBalanceAfter, AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM, + AgencyPointShareBase: hostPointPolicy.AgencyPointShareBase, + AgencyPointShareBaseAmount: agencyPointShareBaseAmount, PointsPerUSD: hostPointPolicy.PointsPerUSD, RoomID: command.RoomID, RoomRegionID: command.RegionID, @@ -431,6 +449,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount + if agencyPointState == targetPointState { + // owner=self 的两个角色共享一个 POINT 账户;同步回执和 HostPointCredited 必须返回整笔 + // 交易完成后的最终余额,而不是只完成主播分录时的中间余额。 + metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount + } if err := r.insertEntry(ctx, tx, walletEntry{ TransactionID: transactionID, UserID: command.TargetAgencyOwnerUserID, @@ -483,12 +506,29 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm if giftIncomeCoinAmount > 0 { events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) } + sharedHostAgencyPointAccount := hostPointAdded > 0 && agencyPointAdded > 0 && command.TargetUserID == command.TargetAgencyOwnerUserID + sharedPointDelta := int64(0) + if sharedHostAgencyPointAccount { + sharedPointDelta, err = checkedAdd(hostPointAdded, agencyPointAdded) + if err != nil { + return ledger.Receipt{}, err + } + } if hostPointAdded > 0 { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType)) + if sharedHostAgencyPointAccount { + // 公会长自己收礼时两种角色写同一个 POINT 账户。账本仍保留两条分录和两个角色事实, + // 但通用余额事件的幂等 ID 只含 transaction/user/asset;必须合并成最终净变动,否则第二条 + // WalletBalanceChanged 会撞主键并让整笔扣费事务回滚。 + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType)) + } else { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType)) + } events = append(events, hostPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) } if agencyPointAdded > 0 { - events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType)) + if !sharedHostAgencyPointAccount { + events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType)) + } events = append(events, agencyPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs)) } } diff --git a/services/wallet-service/internal/storage/mysql/gift_point_policy.go b/services/wallet-service/internal/storage/mysql/gift_point_policy.go new file mode 100644 index 00000000..3a97b7c1 --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_point_policy.go @@ -0,0 +1,53 @@ +package mysql + +import "hyapp/pkg/xerr" + +// giftPointPolicyAmounts 把一次付费礼物拆成主播 POINT 与 Agency POINT 两个独立账务分量。 +// AgencyShareBaseAmount 一并固化到审计流水,避免策略发布后只能看到 20% 却无法证明当时的计算基数。 +type giftPointPolicyAmounts struct { + HostPointAdded int64 + AgencyPointAdded int64 + AgencyShareBaseAmount int64 +} + +// calculateGiftPointPolicyAmounts 只处理金额公式,不决定 Agency 资格;owner 快照仍由 gateway 在 +// 送礼受理时从 user-service 获取,并由调用方在 owner_id>0 时才真正写 Agency 账。 +func calculateGiftPointPolicyAmounts(chargeAmount int64, giftTypeRatio giftDiamondRatioSnapshot, policy walletPolicyInstance) (giftPointPolicyAmounts, error) { + if chargeAmount < 0 || giftTypeRatio.PPM < 0 || giftTypeRatio.PPM > 1_000_000 { + return giftPointPolicyAmounts{}, xerr.New(xerr.InvalidArgument, "gift point policy amount is invalid") + } + + hostBaseAmount := chargeAmount + if policy.HostGiftTypeRatioEnabled { + // Fami 的普通/幸运/超级幸运礼物先沿用主播原有 100%/10%/1% 钻石口径;区域覆盖也在 + // 调用本函数前由 wallet owner 解析。这里保留两阶段向下取整,严格对应“主播实收后的 20%”。 + var err error + hostBaseAmount, err = giftDiamondAmount(chargeAmount, giftTypeRatio.PPM) + if err != nil { + return giftPointPolicyAmounts{}, err + } + } + hostPointAdded, err := giftDiamondAmount(hostBaseAmount, policy.HostPointRatioPPM) + if err != nil { + return giftPointPolicyAmounts{}, err + } + + agencyShareBaseAmount := chargeAmount + switch policy.AgencyPointShareBase { + case agencyPointShareBaseChargeAmount: + // 旧政策直接从礼物实扣金额切分 Agency POINT,保持已发布 Huwaa/Fami 历史实例兼容。 + case agencyPointShareBaseHostIncome: + agencyShareBaseAmount = hostPointAdded + default: + return giftPointPolicyAmounts{}, xerr.New(xerr.InvalidArgument, "wallet policy agency share base is invalid") + } + agencyPointAdded, err := giftDiamondAmount(agencyShareBaseAmount, policy.AgencyPointRatioPPM) + if err != nil { + return giftPointPolicyAmounts{}, err + } + return giftPointPolicyAmounts{ + HostPointAdded: hostPointAdded, + AgencyPointAdded: agencyPointAdded, + AgencyShareBaseAmount: agencyShareBaseAmount, + }, nil +} diff --git a/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go b/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go new file mode 100644 index 00000000..39f5ba4a --- /dev/null +++ b/services/wallet-service/internal/storage/mysql/gift_point_policy_test.go @@ -0,0 +1,64 @@ +package mysql + +import "testing" + +func TestCalculateGiftPointPolicyAmountsUsesActualHostIncomeForAgencyBonus(t *testing.T) { + policy := walletPolicyInstance{ + HostPointRatioPPM: 1_000_000, + HostGiftTypeRatioEnabled: true, + AgencyPointRatioPPM: 200_000, + AgencyPointShareBase: agencyPointShareBaseHostIncome, + } + cases := []struct { + name string + typeRatio int64 + hostPoint int64 + agencyPoint int64 + }{ + {name: "normal", typeRatio: 1_000_000, hostPoint: 10_000, agencyPoint: 2_000}, + {name: "lucky", typeRatio: 100_000, hostPoint: 1_000, agencyPoint: 200}, + {name: "super lucky", typeRatio: 10_000, hostPoint: 100, agencyPoint: 20}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + amounts, err := calculateGiftPointPolicyAmounts(10_000, giftDiamondRatioSnapshot{PPM: tc.typeRatio}, policy) + if err != nil { + t.Fatalf("calculate gift point policy amounts: %v", err) + } + if amounts.HostPointAdded != tc.hostPoint || amounts.AgencyShareBaseAmount != tc.hostPoint || amounts.AgencyPointAdded != tc.agencyPoint { + t.Fatalf("unexpected amounts: %+v", amounts) + } + }) + } +} + +func TestCalculateGiftPointPolicyAmountsPreservesLegacyChargeBase(t *testing.T) { + amounts, err := calculateGiftPointPolicyAmounts(10_000, giftDiamondRatioSnapshot{PPM: 10_000}, walletPolicyInstance{ + HostPointRatioPPM: 700_000, + AgencyPointRatioPPM: 200_000, + AgencyPointShareBase: agencyPointShareBaseChargeAmount, + }) + if err != nil { + t.Fatalf("calculate legacy policy amounts: %v", err) + } + // 旧策略没有开启礼物类型倍率,并直接按实扣金额计算 Agency 分成,确保已发布的 70%+20% 快照不变义。 + if amounts.HostPointAdded != 7_000 || amounts.AgencyShareBaseAmount != 10_000 || amounts.AgencyPointAdded != 2_000 { + t.Fatalf("unexpected legacy amounts: %+v", amounts) + } +} + +func TestCalculateGiftPointPolicyAmountsRoundsAgencyFromRoundedHostIncome(t *testing.T) { + amounts, err := calculateGiftPointPolicyAmounts(499, giftDiamondRatioSnapshot{PPM: 10_000}, walletPolicyInstance{ + HostPointRatioPPM: 1_000_000, + HostGiftTypeRatioEnabled: true, + AgencyPointRatioPPM: 200_000, + AgencyPointShareBase: agencyPointShareBaseHostIncome, + }) + if err != nil { + t.Fatalf("calculate rounded policy amounts: %v", err) + } + // 499 * 1% 先向下取整为主播实收 4;Agency 再按该 4 的 20% 向下取整为 0,不能从原价直接算出 99。 + if amounts.HostPointAdded != 4 || amounts.AgencyShareBaseAmount != 4 || amounts.AgencyPointAdded != 0 { + t.Fatalf("unexpected rounded amounts: %+v", amounts) + } +} diff --git a/services/wallet-service/internal/storage/mysql/metadata.go b/services/wallet-service/internal/storage/mysql/metadata.go index f7c73725..90f78d1d 100644 --- a/services/wallet-service/internal/storage/mysql/metadata.go +++ b/services/wallet-service/internal/storage/mysql/metadata.go @@ -42,30 +42,35 @@ type giftMetadata struct { LastRechargedAtMS int64 `json:"last_recharged_at_ms,omitempty"` // PaidAtMS 与 wallet_transactions.created_at_ms 同源;写进 metadata 让新交易直接重放, // 历史 metadata 缺字段时 receipt 查询会从交易主表创建时间补齐。 - PaidAtMS int64 `json:"paid_at_ms,omitempty"` - BillingReceipt string `json:"billing_receipt_id"` - SenderUserID int64 `json:"sender_user_id"` - SenderRegionID int64 `json:"sender_region_id"` - TargetUserID int64 `json:"target_user_id"` - TargetIsHost bool `json:"target_is_host"` - TargetHostRegionID int64 `json:"target_host_region_id"` - TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` - HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` - GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` - GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` - HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` - HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` - HostPeriodCycleKey string `json:"host_period_cycle_key"` - HostPointAssetType string `json:"host_point_asset_type,omitempty"` - HostPointAdded int64 `json:"host_point_added,omitempty"` - HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"` - HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"` - HostPointTemplateCode string `json:"host_point_template_code,omitempty"` - HostPointTemplateVersion string `json:"host_point_template_version,omitempty"` - HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"` - AgencyPointAdded int64 `json:"agency_point_added,omitempty"` - AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"` - AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"` + PaidAtMS int64 `json:"paid_at_ms,omitempty"` + BillingReceipt string `json:"billing_receipt_id"` + SenderUserID int64 `json:"sender_user_id"` + SenderRegionID int64 `json:"sender_region_id"` + TargetUserID int64 `json:"target_user_id"` + TargetIsHost bool `json:"target_is_host"` + TargetHostRegionID int64 `json:"target_host_region_id"` + TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"` + HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"` + GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"` + GiftDiamondRatioPPM int64 `json:"gift_diamond_ratio_ppm,omitempty"` + GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"` + HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"` + HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"` + HostPeriodCycleKey string `json:"host_period_cycle_key"` + HostPointAssetType string `json:"host_point_asset_type,omitempty"` + HostPointAdded int64 `json:"host_point_added,omitempty"` + HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"` + HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"` + HostPointTemplateCode string `json:"host_point_template_code,omitempty"` + HostPointTemplateVersion string `json:"host_point_template_version,omitempty"` + HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"` + AgencyPointAdded int64 `json:"agency_point_added,omitempty"` + AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"` + AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"` + // AgencyPointShareBase/Amount 是分成公式与基数的不可变交易快照;只存 ratio 无法区分旧的 + // charge_amount 口径和 Fami 新的 host_income 额外收益口径。 + AgencyPointShareBase string `json:"agency_point_share_base,omitempty"` + AgencyPointShareBaseAmount int64 `json:"agency_point_share_base_amount,omitempty"` PointsPerUSD int64 `json:"points_per_usd,omitempty"` RoomID string `json:"room_id"` RoomRegionID int64 `json:"room_region_id"` diff --git a/services/wallet-service/internal/storage/mysql/outbox.go b/services/wallet-service/internal/storage/mysql/outbox.go index 79c0cd11..98e45dbb 100644 --- a/services/wallet-service/internal/storage/mysql/outbox.go +++ b/services/wallet-service/internal/storage/mysql/outbox.go @@ -816,27 +816,30 @@ func hostPointCreditedEvent(transactionID string, commandID string, metadata gif AvailableDelta: metadata.HostPointAdded, FrozenDelta: 0, Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "region_id": metadata.TargetHostRegionID, - "asset_type": ledger.AssetPoint, - "point_delta": metadata.HostPointAdded, - "point_balance_after": metadata.HostPointBalanceAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "charge_source": metadata.ChargeSource, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "policy_instance_code": metadata.HostPointPolicyInstanceCode, - "policy_template_code": metadata.HostPointTemplateCode, - "policy_template_version": metadata.HostPointTemplateVersion, - "host_point_ratio_ppm": metadata.HostPointRatioPPM, - "points_per_usd": metadata.PointsPerUSD, - "allow_self_brushing": true, - "created_at_ms": nowMs, + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "region_id": metadata.TargetHostRegionID, + "asset_type": ledger.AssetPoint, + "point_delta": metadata.HostPointAdded, + "point_balance_after": metadata.HostPointBalanceAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "charge_source": metadata.ChargeSource, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "policy_instance_code": metadata.HostPointPolicyInstanceCode, + "policy_template_code": metadata.HostPointTemplateCode, + "policy_template_version": metadata.HostPointTemplateVersion, + "host_point_ratio_ppm": metadata.HostPointRatioPPM, + "gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent, + "gift_diamond_ratio_ppm": metadata.GiftDiamondRatioPPM, + "gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID, + "points_per_usd": metadata.PointsPerUSD, + "allow_self_brushing": true, + "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } @@ -853,24 +856,26 @@ func agencyPointCreditedEvent(transactionID string, commandID string, metadata g AvailableDelta: metadata.AgencyPointAdded, FrozenDelta: 0, Payload: map[string]any{ - "transaction_id": transactionID, - "command_id": commandID, - "host_user_id": metadata.TargetUserID, - "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, - "sender_user_id": metadata.SenderUserID, - "room_id": metadata.RoomID, - "asset_type": ledger.AssetPoint, - "point_delta": metadata.AgencyPointAdded, - "point_balance_after": metadata.AgencyPointBalanceAfter, - "gift_charge_asset_type": metadata.ChargeAssetType, - "gift_charge_amount": metadata.ChargeAmount, - "gift_id": metadata.GiftID, - "gift_count": metadata.GiftCount, - "policy_instance_code": metadata.HostPointPolicyInstanceCode, - "policy_template_code": metadata.HostPointTemplateCode, - "policy_template_version": metadata.HostPointTemplateVersion, - "agency_point_ratio_ppm": metadata.AgencyPointRatioPPM, - "created_at_ms": nowMs, + "transaction_id": transactionID, + "command_id": commandID, + "host_user_id": metadata.TargetUserID, + "agency_owner_user_id": metadata.TargetAgencyOwnerUserID, + "sender_user_id": metadata.SenderUserID, + "room_id": metadata.RoomID, + "asset_type": ledger.AssetPoint, + "point_delta": metadata.AgencyPointAdded, + "point_balance_after": metadata.AgencyPointBalanceAfter, + "gift_charge_asset_type": metadata.ChargeAssetType, + "gift_charge_amount": metadata.ChargeAmount, + "gift_id": metadata.GiftID, + "gift_count": metadata.GiftCount, + "policy_instance_code": metadata.HostPointPolicyInstanceCode, + "policy_template_code": metadata.HostPointTemplateCode, + "policy_template_version": metadata.HostPointTemplateVersion, + "agency_point_ratio_ppm": metadata.AgencyPointRatioPPM, + "agency_share_base": metadata.AgencyPointShareBase, + "agency_share_base_amount": metadata.AgencyPointShareBaseAmount, + "created_at_ms": nowMs, }, CreatedAtMS: nowMs, } diff --git a/services/wallet-service/internal/storage/mysql/policy_instance.go b/services/wallet-service/internal/storage/mysql/policy_instance.go index 47a518bd..601b15bd 100644 --- a/services/wallet-service/internal/storage/mysql/policy_instance.go +++ b/services/wallet-service/internal/storage/mysql/policy_instance.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "strconv" "strings" "hyapp/pkg/appcode" @@ -11,16 +12,27 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) +const ( + // charge_amount 是 ca15f098 上线时的兼容口径:Agency 比例直接作用于礼物实扣金额, + // 因而主播比例与 Agency 比例必须共同受 100% 资金池约束。 + agencyPointShareBaseChargeAmount = "charge_amount" + // host_income 表示平台在主播本次实际 POINT 收益之外追加 Agency 奖励。 + // 该口径先完成礼物类型和主播政策换算,再对最终主播收益取比例,允许出现 100%+20%。 + agencyPointShareBaseHostIncome = "host_income" +) + type walletPolicyInstance struct { - InstanceCode string - TemplateCode string - TemplateVersion string - RegionID int64 - HostPointAsset string - HostPointRatioPPM int64 - AgencyPointRatioPPM int64 - PointsPerUSD int64 - WithdrawFeeBPS int64 + InstanceCode string + TemplateCode string + TemplateVersion string + RegionID int64 + HostPointAsset string + HostPointRatioPPM int64 + HostGiftTypeRatioEnabled bool + AgencyPointRatioPPM int64 + AgencyPointShareBase string + PointsPerUSD int64 + WithdrawFeeBPS int64 } func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMS int64) (walletPolicyInstance, bool, error) { @@ -30,7 +42,11 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, } row := tx.QueryRowContext(ctx, ` SELECT instance_code, template_code, template_version, region_id, host_point_asset_type, - host_point_ratio_ppm, agency_point_ratio_ppm, points_per_usd, withdraw_fee_bps + host_point_ratio_ppm, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.use_gift_type_ratio')), 'false'), + agency_point_ratio_ppm, + COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.agency.share_base')), 'charge_amount'), + points_per_usd, withdraw_fee_bps FROM wallet_policy_instances WHERE app_code = ? AND region_id IN (?, 0) @@ -42,7 +58,20 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, appCode, regionID, nowMS, nowMS, regionID, ) var item walletPolicyInstance - if err := row.Scan(&item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionID, &item.HostPointAsset, &item.HostPointRatioPPM, &item.AgencyPointRatioPPM, &item.PointsPerUSD, &item.WithdrawFeeBPS); err != nil { + var hostGiftTypeRatioRaw string + if err := row.Scan( + &item.InstanceCode, + &item.TemplateCode, + &item.TemplateVersion, + &item.RegionID, + &item.HostPointAsset, + &item.HostPointRatioPPM, + &hostGiftTypeRatioRaw, + &item.AgencyPointRatioPPM, + &item.AgencyPointShareBase, + &item.PointsPerUSD, + &item.WithdrawFeeBPS, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { return walletPolicyInstance{}, false, nil } @@ -52,8 +81,33 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, if item.HostPointAsset == "" { item.HostPointAsset = ledger.AssetPoint } - if item.HostPointAsset != ledger.AssetPoint || item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 || item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 || item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 { + parsedGiftTypeRatio, err := strconv.ParseBool(strings.ToLower(strings.TrimSpace(hostGiftTypeRatioRaw))) + if err != nil { + return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy host gift type ratio flag is invalid") + } + item.HostGiftTypeRatioEnabled = parsedGiftTypeRatio + item.AgencyPointShareBase = strings.ToLower(strings.TrimSpace(item.AgencyPointShareBase)) + if item.AgencyPointShareBase == "" { + item.AgencyPointShareBase = agencyPointShareBaseChargeAmount + } + if item.HostPointAsset != ledger.AssetPoint || + item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || + item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 || + item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 { return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid") } + switch item.AgencyPointShareBase { + case agencyPointShareBaseChargeAmount: + // 旧实例没有 share_base,必须继续执行原先的总资金池守恒约束;否则一次重新发布就会 + // 把历史 70%+20% 配置悄然解释成可超发政策。 + if item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 { + return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid") + } + case agencyPointShareBaseHostIncome: + // host_income 是额外平台奖励,Agency 百分比相对于主播实际到账值独立校验,不能与 + // host_point_ratio_ppm 相加后套用旧的 100% 上限。 + default: + return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy agency share base is invalid") + } return item, true, nil } diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go index 33cfd4ed..71a62245 100644 --- a/services/wallet-service/internal/storage/mysql/schema.go +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -372,7 +372,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效', host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产', host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm', - agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm', + agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm', points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例', withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps', rule_json JSON NOT NULL COMMENT '完整政策快照', @@ -386,7 +386,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { if err != nil { return err } - if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) { + if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定,ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) { return err } _, err = db.ExecContext(ctx, ` @@ -399,13 +399,26 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error { room_id VARCHAR(96) NOT NULL DEFAULT '', point_delta BIGINT NOT NULL, ratio_ppm BIGINT NOT NULL, + share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown', + share_base_amount BIGINT NOT NULL DEFAULT 0, policy_instance_code VARCHAR(96) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, transaction_id, agency_owner_user_id), KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms), KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`) - return err + if err != nil { + return err + } + // 存量库由旧版本创建表时没有公式基数字段;启动补列必须幂等,并在新代码接受送礼前完成, + // 否则账本已加钱而审计流水 INSERT 失败只能依赖事务回滚兜底。 + if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数:unknown/charge_amount/host_income' AFTER ratio_ppm`); err != nil && !isDuplicateColumnError(err) { + return err + } + if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数;share_base_type=unknown 时数值不可解释' AFTER share_base_type`); err != nil && !isDuplicateColumnError(err) { + return err + } + return nil } // ensurePointWithdrawalCoinSellerSchema 创建跨 Fami/Huwaa/Lalu 共用、按 app_code 隔离的提现币商配置。 diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index ad19d1c0..8de6ae58 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -459,6 +459,47 @@ func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instance r.SeedWalletPolicyInstanceWithRates(appCode, instanceCode, regionID, status, hostPointRatioPPM, agencyPointRatioPPM, 100000, 500) } +// SeedWalletGiftRevenuePolicy 写入送礼收益公式快照,专门覆盖“主播先按礼物类型折算、Agency 再按主播收入分成”的政策。 +// 旧 SeedWalletPolicyInstance* helper 继续写空 rule_json,避免新增测试夹具悄悄改变历史用例的 charge_amount 结算语义。 +func (r *Repository) SeedWalletGiftRevenuePolicy(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, hostUseGiftTypeRatio bool, agencyShareBase string) { + r.t.Helper() + + appCode = strings.TrimSpace(appCode) + instanceCode = strings.TrimSpace(instanceCode) + status = strings.TrimSpace(status) + agencyShareBase = strings.TrimSpace(agencyShareBase) + nowMs := time.Now().UnixMilli() + ruleJSON, err := json.Marshal(map[string]any{ + "host": map[string]any{ + "use_gift_type_ratio": hostUseGiftTypeRatio, + }, + "agency": map[string]any{ + "share_base": agencyShareBase, + }, + }) + if err != nil { + r.t.Fatalf("marshal wallet gift revenue policy failed: %v", err) + } + _, err = r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO wallet_policy_instances ( + app_code, instance_code, template_code, template_version, region_id, status, + effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm, + points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id, + published_at_ms, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, 100000, 500, ?, 90001, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + host_point_ratio_ppm = VALUES(host_point_ratio_ppm), + agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm), + rule_json = VALUES(rule_json), + published_at_ms = VALUES(published_at_ms), + updated_at_ms = VALUES(updated_at_ms) + `, appCode, instanceCode, instanceCode+"_template", regionID, status, hostPointRatioPPM, agencyPointRatioPPM, string(ruleJSON), nowMs, nowMs, nowMs) + if err != nil { + r.t.Fatalf("seed wallet gift revenue policy failed: %v", err) + } +} + // SeedWalletPolicyInstanceWithRates 让 POINT 提现和兑币测试都从同一份已发布政策读取分母。 func (r *Repository) SeedWalletPolicyInstanceWithRates(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, pointsPerUSD int64, feeBPS int64) { r.t.Helper() @@ -651,18 +692,23 @@ func (r *Repository) SetGiftType(giftID string, giftTypeCode string) { // SetGiftDiamondRatio configures the gift diamond ratio for a room region and gift type. func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) { + r.SetGiftDiamondRatioForApp("lalu", regionID, giftTypeCode, ratioPercent) +} + +// SetGiftDiamondRatioForApp writes an app-isolated ratio so multi-tenant policy tests can prove the effective-region snapshot. +func (r *Repository) SetGiftDiamondRatioForApp(appCode string, regionID int64, giftTypeCode string, ratioPercent string) { r.t.Helper() nowMs := time.Now().UnixMilli() _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO gift_diamond_ratio_configs ( - region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, + app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, 'active', ?, ?, 0, 0, ?, ?) + ) VALUES (?, ?, ?, 'active', ?, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), ratio_percent = VALUES(ratio_percent), updated_at_ms = VALUES(updated_at_ms)`, - regionID, giftTypeCode, ratioPercent, defaultGiftReturnCoinRatioPercent(giftTypeCode), nowMs, nowMs) + strings.TrimSpace(appCode), regionID, giftTypeCode, ratioPercent, defaultGiftReturnCoinRatioPercent(giftTypeCode), nowMs, nowMs) if err != nil { r.t.Fatalf("set gift diamond ratio failed: %v", err) }