feat(wallet): support configurable agency gift income sharing
This commit is contained in:
parent
5e124361f8
commit
3d4fd6c94d
@ -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。
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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';
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 实际策略一致。
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 隔离的提现币商配置。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user