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_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"`
|
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"`
|
LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
|
||||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的
|
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益:
|
||||||
// host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
|
// 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"`
|
GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"`
|
||||||
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
||||||
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。
|
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。
|
||||||
|
|||||||
@ -40,8 +40,9 @@ message LuckyGiftMeta {
|
|||||||
int64 recharge_7d_coins = 19;
|
int64 recharge_7d_coins = 19;
|
||||||
int64 recharge_30d_coins = 20;
|
int64 recharge_30d_coins = 20;
|
||||||
int64 last_recharged_at_ms = 21;
|
int64 last_recharged_at_ms = 21;
|
||||||
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的
|
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益:
|
||||||
// host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
|
// POINT 政策读 host_point_added,旧链路读 host_period_diamond_added;Agency 额外收益不包含在内。
|
||||||
|
// 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
|
||||||
int64 gift_income_coins = 22;
|
int64 gift_income_coins = 22;
|
||||||
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
|
||||||
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。
|
// 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/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/002_resource_shop_price_tiers.sql"
|
||||||
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.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
|
# Keep MySQL as the only required dependency for this script. Business services
|
||||||
|
|||||||
@ -26,6 +26,9 @@ const (
|
|||||||
|
|
||||||
policyTaskRewardAssetCoin = "COIN"
|
policyTaskRewardAssetCoin = "COIN"
|
||||||
|
|
||||||
|
agencyShareBaseChargeAmount = "charge_amount"
|
||||||
|
agencyShareBaseHostIncome = "host_income"
|
||||||
|
|
||||||
defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1"
|
defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1"
|
||||||
defaultPolicyTemplateVersion = "v1"
|
defaultPolicyTemplateVersion = "v1"
|
||||||
)
|
)
|
||||||
@ -65,7 +68,9 @@ type policyInstanceRow struct {
|
|||||||
|
|
||||||
type compiledPolicy struct {
|
type compiledPolicy struct {
|
||||||
HostPointRatioPPM int64
|
HostPointRatioPPM int64
|
||||||
|
HostUseGiftTypeRatio bool
|
||||||
AgencyPointRatioPPM int64
|
AgencyPointRatioPPM int64
|
||||||
|
AgencyShareBase string
|
||||||
PointsPerUSD int64
|
PointsPerUSD int64
|
||||||
WithdrawFeeBPS int64
|
WithdrawFeeBPS int64
|
||||||
TaskRewardAssetType string
|
TaskRewardAssetType string
|
||||||
@ -565,7 +570,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
|||||||
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
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 展示换算比例',
|
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||||
@ -580,7 +585,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -650,15 +655,28 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
|
|||||||
pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000)
|
pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000)
|
||||||
host, _ := decoded["host"].(map[string]any)
|
host, _ := decoded["host"].(map[string]any)
|
||||||
hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70)
|
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, _ := decoded["agency"].(map[string]any)
|
||||||
// Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0,避免发布动作隐式改变存量 App 账务。
|
// Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0,避免发布动作隐式改变存量 App 账务。
|
||||||
agencyRatioPercent := floatFromJSON(agency["point_ratio_percent"], 0)
|
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)
|
withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500)
|
||||||
ratioPPM := int64(math.Round(hostRatioPercent * 10000))
|
ratioPPM := int64(math.Round(hostRatioPercent * 10000))
|
||||||
agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000))
|
agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000))
|
||||||
tasks, _ := decoded["tasks"].(map[string]any)
|
tasks, _ := decoded["tasks"].(map[string]any)
|
||||||
taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin)))
|
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")
|
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
|
||||||
}
|
}
|
||||||
if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" {
|
if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" {
|
||||||
@ -666,14 +684,47 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
|
|||||||
}
|
}
|
||||||
return compiledPolicy{
|
return compiledPolicy{
|
||||||
HostPointRatioPPM: ratioPPM,
|
HostPointRatioPPM: ratioPPM,
|
||||||
|
HostUseGiftTypeRatio: hostUseGiftTypeRatio,
|
||||||
AgencyPointRatioPPM: agencyRatioPPM,
|
AgencyPointRatioPPM: agencyRatioPPM,
|
||||||
|
AgencyShareBase: agencyShareBase,
|
||||||
PointsPerUSD: pointsPerUSD,
|
PointsPerUSD: pointsPerUSD,
|
||||||
WithdrawFeeBPS: withdrawFeeBPS,
|
WithdrawFeeBPS: withdrawFeeBPS,
|
||||||
TaskRewardAssetType: taskRewardAssetType,
|
TaskRewardAssetType: taskRewardAssetType,
|
||||||
|
// 不为编译字段增设运行表列;owner service 必须收到完整规则快照,才能按礼物类型和收益基数解释账务。
|
||||||
RuleJSON: raw,
|
RuleJSON: raw,
|
||||||
}, nil
|
}, 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) {
|
func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) {
|
||||||
var item templateDTO
|
var item templateDTO
|
||||||
var rule string
|
var rule string
|
||||||
|
|||||||
@ -205,6 +205,9 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin
|
|||||||
if fami.AgencyPointRatioPPM != 0 {
|
if fami.AgencyPointRatioPPM != 0 {
|
||||||
t.Fatalf("legacy policy without agency section must remain zero: %+v", fami)
|
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}}`))
|
configured, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70},"agency":{"point_ratio_percent":20}}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("compile configured policy failed: %v", err)
|
t.Fatalf("compile configured policy failed: %v", err)
|
||||||
@ -212,6 +215,46 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin
|
|||||||
if configured.AgencyPointRatioPPM != 200000 {
|
if configured.AgencyPointRatioPPM != 200000 {
|
||||||
t.Fatalf("configured agency ratio mismatch: %+v", configured)
|
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 {
|
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);
|
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
|
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
|
updated_at_ms = @now_ms
|
||||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
||||||
AND template_version = '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
|
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
|
updated_at_ms = @now_ms
|
||||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
|
||||||
AND template_version = '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 (
|
INSERT INTO admin_policy_templates (
|
||||||
template_code, template_version, name, status, rule_json, description,
|
template_code, template_version, name, status, rule_json, description,
|
||||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
'fami_guild_revenue_policy', 'v1', 'Fami 公会收益政策', 'active',
|
'fami_guild_revenue_policy', 'v1', 'Fami 公会收益政策', 'active',
|
||||||
JSON_SET(rule_json, '$.agency.point_ratio_percent', 20),
|
JSON_SET(
|
||||||
'Fami 主播与 Agency POINT 收益政策;Agency 默认 20%,以发布后的运行快照为准。',
|
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
|
0, 0, @now_ms, @now_ms
|
||||||
FROM admin_policy_templates
|
FROM admin_policy_templates
|
||||||
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1'
|
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
|
Recharge7DCoins int64
|
||||||
Recharge30DCoins int64
|
Recharge30DCoins int64
|
||||||
LastRechargedAtMS int64
|
LastRechargedAtMS int64
|
||||||
// HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。
|
// HostPeriodDiamondAdded 是兼容字段名,实际承载 wallet 已落账的主播收益(POINT 或周期钻石);
|
||||||
|
// Agency 额外收益不进入幸运礼物资金拆分,服务也不能自行重算主播比例。
|
||||||
HostPeriodDiamondAdded int64
|
HostPeriodDiamondAdded int64
|
||||||
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
// Sender* 是扣费时刻的公开展示快照;owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
|
||||||
SenderName string
|
SenderName string
|
||||||
|
|||||||
@ -129,12 +129,25 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
|
|||||||
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
|
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
|
||||||
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
|
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
|
||||||
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
|
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
|
||||||
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台
|
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 实际给主播
|
||||||
// “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。
|
// 结算的收益:新 POINT 政策读 host_point_added,旧工资钻石链路读 host_period_diamond_added。
|
||||||
GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(),
|
// Agency 额外 20% 不属于主播收益,绝不能混入幸运礼物公共池/平台利润的守恒拆分。
|
||||||
|
GiftIncomeCoins: walletHostGiftIncome(targetBilling.Billing),
|
||||||
}, nil
|
}, 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 {
|
func normalizeLuckyGiftStrategyVersion(value string) string {
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@ -127,6 +127,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
GiftPointAdded: 100,
|
GiftPointAdded: 100,
|
||||||
HeatValue: 100,
|
HeatValue: 100,
|
||||||
GiftTypeCode: "super_lucky",
|
GiftTypeCode: "super_lucky",
|
||||||
|
HostPointAdded: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
BillingReceiptId: "receipt-lucky-legacy-token",
|
BillingReceiptId: "receipt-lucky-legacy-token",
|
||||||
@ -134,6 +135,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
GiftPointAdded: 100,
|
GiftPointAdded: 100,
|
||||||
HeatValue: 100,
|
HeatValue: 100,
|
||||||
GiftTypeCode: "super_lucky",
|
GiftTypeCode: "super_lucky",
|
||||||
|
HostPeriodDiamondAdded: 1,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
luckyGift := &luckyGiftTestClient{}
|
luckyGift := &luckyGiftTestClient{}
|
||||||
@ -194,6 +196,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
drawMeta.GetCoinSpent() != 100 ||
|
drawMeta.GetCoinSpent() != 100 ||
|
||||||
drawMeta.GetDeviceId() != "device-auth-session-1" ||
|
drawMeta.GetDeviceId() != "device-auth-session-1" ||
|
||||||
drawMeta.GetAnchorId() != "101" ||
|
drawMeta.GetAnchorId() != "101" ||
|
||||||
|
drawMeta.GetGiftIncomeCoins() != 7 ||
|
||||||
drawMeta.GetVisibleRegionId() != 9001 ||
|
drawMeta.GetVisibleRegionId() != 9001 ||
|
||||||
drawMeta.GetSenderName() != "lucky sender" ||
|
drawMeta.GetSenderName() != "lucky sender" ||
|
||||||
drawMeta.GetSenderAvatar() != "https://cdn.example.com/sender.png" ||
|
drawMeta.GetSenderAvatar() != "https://cdn.example.com/sender.png" ||
|
||||||
@ -218,6 +221,9 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
|||||||
if legacyDraw.GetDeviceId() != "" {
|
if legacyDraw.GetDeviceId() != "" {
|
||||||
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", 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"
|
luckyGift.checkStrategy = "dynamic_v3"
|
||||||
debitsBeforeDynamicReject := len(wallet.debitRequests)
|
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 表示长期有效',
|
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
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 展示换算比例',
|
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
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',
|
sender_user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
|
||||||
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID,私聊送礼为空',
|
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID,私聊送礼为空',
|
||||||
point_delta BIGINT NOT NULL COMMENT 'Agency owner 本次 POINT 分成',
|
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 '收益策略实例快照',
|
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '收益策略实例快照',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
|
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) {
|
func TestFamiHostGiftPolicyCreditsAgencyShareWithHistoricalOwnershipSnapshot(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
repository.SetBalanceForApp("fami", 32001, 1000)
|
// Fami 公会收益先按礼物类型计算主播实收,再额外给 Agency owner 主播实收的 20%;
|
||||||
repository.SetGiftPriceForApp("fami", "fami-rose", "v1", 100, 0, 100)
|
// 该公式允许一次礼物产生 100% 主播收益和额外 20% 公会收益,不能套用旧的总资金池切分语义。
|
||||||
repository.SeedWalletPolicyInstanceWithAgency("fami", "fami-guild-live", 0, "active", 700000, 200000)
|
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)
|
svc := walletservice.New(repository)
|
||||||
|
|
||||||
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||||||
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
|
AppCode: appCode, CommandID: "cmd-next-app-agency-lucky", RoomID: "room-next-app",
|
||||||
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
|
SenderUserID: 36001, TargetUserID: 36002, TargetIsHost: true,
|
||||||
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
|
TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36003,
|
||||||
|
GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 {
|
if receipt.HostPointAdded != 2_500 || receipt.HostPointBalanceAfter != 2_500 || receipt.HostPointPolicyInstanceCode != "shared-agency-host-income" {
|
||||||
t.Fatalf("Fami host point receipt mismatch: %+v", receipt)
|
t.Fatalf("cross-app host revenue mismatch: %+v", receipt)
|
||||||
}
|
}
|
||||||
assertBalanceForApp(t, svc, "fami", 32002, ledger.AssetPoint, 70)
|
assertBalanceForApp(t, svc, appCode, 36002, ledger.AssetPoint, 2_500)
|
||||||
assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20)
|
assertBalanceForApp(t, svc, appCode, 36003, ledger.AssetPoint, 500)
|
||||||
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 {
|
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("Fami agency share snapshot mismatch, got %d", got)
|
t.Fatalf("cross-app effective-region ratio 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replayed, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
repository.SetBalanceForApp(appCode, 36011, 10_000)
|
||||||
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
|
batch, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
|
||||||
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
|
AppCode: appCode, CommandID: "cmd-next-app-agency-lucky-batch", RoomID: "room-next-app",
|
||||||
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Fami agency share replay mismatch: receipt=%+v err=%v", replayed, err)
|
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) {
|
func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) {
|
||||||
|
|||||||
@ -16,10 +16,12 @@ func (r *Repository) insertAgencyPointShare(ctx context.Context, tx *sql.Tx, tra
|
|||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO agency_point_share_entries (
|
INSERT INTO agency_point_share_entries (
|
||||||
app_code, transaction_id, host_user_id, agency_owner_user_id, sender_user_id,
|
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
|
room_id, point_delta, ratio_ppm, share_base_type, share_base_amount,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
policy_instance_code, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID,
|
appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID,
|
||||||
metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM,
|
metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM,
|
||||||
|
metadata.AgencyPointShareBase, metadata.AgencyPointShareBaseAmount,
|
||||||
metadata.HostPointPolicyInstanceCode, nowMS,
|
metadata.HostPointPolicyInstanceCode, nowMS,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -118,11 +118,35 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
return ledger.BatchGiftReceipt{}, err
|
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 {
|
type targetHostPointPolicy struct {
|
||||||
policy walletPolicyInstance
|
policy walletPolicyInstance
|
||||||
amount int64
|
amount int64
|
||||||
userID int64
|
userID int64
|
||||||
|
giftRatioPercent string
|
||||||
|
giftRatioPPM int64
|
||||||
|
giftRatioRegionID int64
|
||||||
agencyAmount int64
|
agencyAmount int64
|
||||||
|
agencyShareBaseAmount int64
|
||||||
agencyOwnerUserID int64
|
agencyOwnerUserID int64
|
||||||
active bool
|
active bool
|
||||||
}
|
}
|
||||||
@ -143,23 +167,41 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
if !active {
|
if !active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 批量礼物逐目标判断主播收益政策;自送礼不排除,背包礼物已在外层过滤。
|
// 批量目标逐一按其政策和主播区域计算;同一礼物不能拿第一个主播的区域或收益覆盖后续目标。
|
||||||
amount, err := giftDiamondAmount(chargeAmount, policy.HostPointRatioPPM)
|
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 {
|
if err != nil {
|
||||||
return ledger.BatchGiftReceipt{}, err
|
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)
|
agencyAmount := int64(0)
|
||||||
|
agencyShareBaseAmount := int64(0)
|
||||||
if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 {
|
if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 {
|
||||||
agencyAmount, err = giftDiamondAmount(chargeAmount, policy.AgencyPointRatioPPM)
|
agencyAmount = pointAmounts.AgencyPointAdded
|
||||||
if err != nil {
|
agencyShareBaseAmount = pointAmounts.AgencyShareBaseAmount
|
||||||
return ledger.BatchGiftReceipt{}, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
hostPointPolicies[target.CommandID] = targetHostPointPolicy{
|
hostPointPolicies[target.CommandID] = targetHostPointPolicy{
|
||||||
policy: policy,
|
policy: policy,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
userID: target.TargetUserID,
|
userID: target.TargetUserID,
|
||||||
|
giftRatioPercent: giftRatioPercent,
|
||||||
|
giftRatioPPM: giftRatioPPM,
|
||||||
|
giftRatioRegionID: giftRatioRegionID,
|
||||||
agencyAmount: agencyAmount,
|
agencyAmount: agencyAmount,
|
||||||
|
agencyShareBaseAmount: agencyShareBaseAmount,
|
||||||
agencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
agencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
||||||
active: true,
|
active: true,
|
||||||
}
|
}
|
||||||
@ -185,15 +227,16 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, hostPointPolicy := range hostPointPolicies {
|
for _, hostPointPolicy := range hostPointPolicies {
|
||||||
if hostPointPolicy.amount <= 0 {
|
if hostPointPolicy.amount > 0 {
|
||||||
continue
|
|
||||||
}
|
|
||||||
lockRequests = append(lockRequests, walletAccountLockRequest{
|
lockRequests = append(lockRequests, walletAccountLockRequest{
|
||||||
UserID: hostPointPolicy.userID,
|
UserID: hostPointPolicy.userID,
|
||||||
AssetType: ledger.AssetPoint,
|
AssetType: ledger.AssetPoint,
|
||||||
CreateIfMissing: true,
|
CreateIfMissing: true,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
if hostPointPolicy.agencyAmount > 0 {
|
if hostPointPolicy.agencyAmount > 0 {
|
||||||
|
// charge_amount 兼容策略可能因两种比例不同而出现 host 向下取整为 0、Agency 仍为正数;
|
||||||
|
// Agency 账户锁不能被 host amount 的判断包住,否则 Direct 成功而 Batch 返回内部错误。
|
||||||
lockRequests = append(lockRequests, walletAccountLockRequest{
|
lockRequests = append(lockRequests, walletAccountLockRequest{
|
||||||
UserID: hostPointPolicy.agencyOwnerUserID,
|
UserID: hostPointPolicy.agencyOwnerUserID,
|
||||||
AssetType: ledger.AssetPoint,
|
AssetType: ledger.AssetPoint,
|
||||||
@ -233,28 +276,15 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
events = append(events, resourceEvent)
|
events = append(events, resourceEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
type resolvedHostDiamondRatio struct {
|
|
||||||
ratio giftDiamondRatioSnapshot
|
|
||||||
effectiveRegionID int64
|
|
||||||
}
|
|
||||||
// 批量送礼中的主播可能属于不同区域,必须逐区域解析周期钻石比例;同一区域只查一次,
|
// 批量送礼中的主播可能属于不同区域,必须逐区域解析周期钻石比例;同一区域只查一次,
|
||||||
// 解析器会在区域配置缺失时回退 region_id=0,既避免 N 次重复查询,也不混用其他主播区域。
|
// 解析器会在区域配置缺失时回退 region_id=0,既避免 N 次重复查询,也不混用其他主播区域。
|
||||||
hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio)
|
|
||||||
for _, target := range command.Targets {
|
for _, target := range command.Targets {
|
||||||
if !target.TargetIsHost || hostPointPolicies[target.CommandID].active {
|
if !target.TargetIsHost || hostPointPolicies[target.CommandID].active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := hostRatiosByRegion[target.TargetHostRegionID]; exists {
|
if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
ratio, effectiveRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, target.TargetHostRegionID, giftConfig.GiftTypeCode)
|
|
||||||
if err != nil {
|
|
||||||
return ledger.BatchGiftReceipt{}, err
|
return ledger.BatchGiftReceipt{}, err
|
||||||
}
|
}
|
||||||
hostRatiosByRegion[target.TargetHostRegionID] = resolvedHostDiamondRatio{
|
|
||||||
ratio: ratio,
|
|
||||||
effectiveRegionID: effectiveRegionID,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
|
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]
|
hostPointPolicy := hostPointPolicies[target.CommandID]
|
||||||
hostPeriodDiamondAdded := int64(0)
|
hostPeriodDiamondAdded := int64(0)
|
||||||
hostPeriodCycleKey := ""
|
hostPeriodCycleKey := ""
|
||||||
giftDiamondRatioPercent := "0.00"
|
giftDiamondRatioPercent := hostPointPolicy.giftRatioPercent
|
||||||
giftDiamondRatioRegionID := int64(0)
|
if giftDiamondRatioPercent == "" {
|
||||||
|
giftDiamondRatioPercent = "0.00"
|
||||||
|
}
|
||||||
|
giftDiamondRatioPPM := hostPointPolicy.giftRatioPPM
|
||||||
|
giftDiamondRatioRegionID := hostPointPolicy.giftRatioRegionID
|
||||||
if target.TargetIsHost && !hostPointPolicy.active {
|
if target.TargetIsHost && !hostPointPolicy.active {
|
||||||
resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID]
|
resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@ -274,6 +308,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
return ledger.BatchGiftReceipt{}, err
|
return ledger.BatchGiftReceipt{}, err
|
||||||
}
|
}
|
||||||
giftDiamondRatioPercent = resolvedRatio.ratio.Percent
|
giftDiamondRatioPercent = resolvedRatio.ratio.Percent
|
||||||
|
giftDiamondRatioPPM = resolvedRatio.ratio.PPM
|
||||||
giftDiamondRatioRegionID = resolvedRatio.effectiveRegionID
|
giftDiamondRatioRegionID = resolvedRatio.effectiveRegionID
|
||||||
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
|
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
|
||||||
}
|
}
|
||||||
@ -373,6 +408,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
|
||||||
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
||||||
GiftDiamondRatioPercent: giftDiamondRatioPercent,
|
GiftDiamondRatioPercent: giftDiamondRatioPercent,
|
||||||
|
GiftDiamondRatioPPM: giftDiamondRatioPPM,
|
||||||
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
|
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
|
||||||
HostPeriodCycleKey: hostPeriodCycleKey,
|
HostPeriodCycleKey: hostPeriodCycleKey,
|
||||||
HostPointAssetType: hostPointPolicy.policy.HostPointAsset,
|
HostPointAssetType: hostPointPolicy.policy.HostPointAsset,
|
||||||
@ -385,6 +421,8 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
AgencyPointAdded: hostPointPolicy.agencyAmount,
|
AgencyPointAdded: hostPointPolicy.agencyAmount,
|
||||||
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
||||||
AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM,
|
AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM,
|
||||||
|
AgencyPointShareBase: hostPointPolicy.policy.AgencyPointShareBase,
|
||||||
|
AgencyPointShareBaseAmount: hostPointPolicy.agencyShareBaseAmount,
|
||||||
PointsPerUSD: hostPointPolicy.policy.PointsPerUSD,
|
PointsPerUSD: hostPointPolicy.policy.PointsPerUSD,
|
||||||
RoomID: command.RoomID,
|
RoomID: command.RoomID,
|
||||||
RoomRegionID: command.RegionID,
|
RoomRegionID: command.RegionID,
|
||||||
@ -473,6 +511,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
return ledger.BatchGiftReceipt{}, err
|
return ledger.BatchGiftReceipt{}, err
|
||||||
}
|
}
|
||||||
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||||
|
if agencyPointState == targetPointState {
|
||||||
|
// 同一 child transaction 的两个角色共用账户时,回执和主播角色事实都以第二条
|
||||||
|
// 分录后的最终 POINT 余额为准,不能暴露本批次内部的中间余额。
|
||||||
|
metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||||
|
}
|
||||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
UserID: hostPointPolicy.agencyOwnerUserID,
|
UserID: hostPointPolicy.agencyOwnerUserID,
|
||||||
@ -521,12 +564,28 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
if targetGiftIncomeCoinAmount > 0 {
|
if targetGiftIncomeCoinAmount > 0 {
|
||||||
events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
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 {
|
if hostPointPolicy.amount > 0 {
|
||||||
|
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, 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))
|
events = append(events, hostPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||||
}
|
}
|
||||||
if hostPointPolicy.agencyAmount > 0 {
|
if hostPointPolicy.agencyAmount > 0 {
|
||||||
|
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, 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))
|
events = append(events, agencyPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
|
||||||
}
|
}
|
||||||
if hostPeriodDiamondAdded > 0 {
|
if hostPeriodDiamondAdded > 0 {
|
||||||
|
|||||||
@ -95,8 +95,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 礼物类型倍率既参与旧周期钻石,也可能参与新 POINT 政策。三项快照在事务内一次解析并落 metadata,
|
||||||
|
// 后续运营调整区域倍率时,历史流水仍能证明 charge_amount 如何得到主播实际收益。
|
||||||
|
giftDiamondRatioPercent := "0.00"
|
||||||
|
giftDiamondRatioPPM := int64(0)
|
||||||
|
giftDiamondRatioRegionID := int64(0)
|
||||||
hostPointAdded := int64(0)
|
hostPointAdded := int64(0)
|
||||||
agencyPointAdded := int64(0)
|
agencyPointAdded := int64(0)
|
||||||
|
agencyPointShareBaseAmount := int64(0)
|
||||||
hostPointPolicy := walletPolicyInstance{}
|
hostPointPolicy := walletPolicyInstance{}
|
||||||
hostPointPolicyActive := false
|
hostPointPolicyActive := false
|
||||||
hostPointRegionID := command.TargetHostRegionID
|
hostPointRegionID := command.TargetHostRegionID
|
||||||
@ -109,26 +115,34 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
if hostPointPolicyActive {
|
if hostPointPolicyActive {
|
||||||
// Huwaa 第一套政策把主播有效付费礼物收益写入 POINT;自送礼不被排除,背包/机器人礼物不属于有效付费礼物。
|
// 旧政策直接以 charge_amount 为主播基数;Fami 新政策显式开启 gift-type ratio 后,
|
||||||
hostPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.HostPointRatioPPM)
|
// 必须先按主播所在区域和礼物类型得到原有钻石收益,再换算 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 {
|
if err != nil {
|
||||||
return ledger.Receipt{}, err
|
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 收益。
|
// 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。
|
||||||
giftIncomeCoinAmount = 0
|
giftIncomeCoinAmount = 0
|
||||||
if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 {
|
if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 {
|
||||||
// Agency 分成只跟随服务端注入的送礼时归属快照,客户端不能指定;自送礼同样属于有效付费礼物。
|
// Agency 资格只跟随 gateway 注入并进入 wallet 幂等哈希的归属快照;用户不能自报 owner。
|
||||||
agencyPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.AgencyPointRatioPPM)
|
agencyPointAdded = pointAmounts.AgencyPointAdded
|
||||||
if err != nil {
|
agencyPointShareBaseAmount = pointAmounts.AgencyShareBaseAmount
|
||||||
return ledger.Receipt{}, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hostPeriodDiamondAdded := int64(0)
|
hostPeriodDiamondAdded := int64(0)
|
||||||
hostPeriodCycleKey := ""
|
hostPeriodCycleKey := ""
|
||||||
giftDiamondRatioPercent := "0.00"
|
|
||||||
giftDiamondRatioRegionID := int64(0)
|
|
||||||
if command.TargetIsHost && !command.RobotGift && !hostPointPolicyActive {
|
if command.TargetIsHost && !command.RobotGift && !hostPointPolicyActive {
|
||||||
// 主播周期钻石按主播身份所属区域结算,而不是房间可见区域或送礼人区域;
|
// 主播周期钻石按主播身份所属区域结算,而不是房间可见区域或送礼人区域;
|
||||||
// 区域未配置时由解析器回退 region_id=0,保证历史全局配置继续生效。
|
// 区域未配置时由解析器回退 region_id=0,保证历史全局配置继续生效。
|
||||||
@ -142,6 +156,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
giftDiamondRatioPercent = ratio.Percent
|
giftDiamondRatioPercent = ratio.Percent
|
||||||
|
giftDiamondRatioPPM = ratio.PPM
|
||||||
giftDiamondRatioRegionID = ratioRegionID
|
giftDiamondRatioRegionID = ratioRegionID
|
||||||
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
|
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
|
||||||
}
|
}
|
||||||
@ -297,6 +312,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
|
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
|
||||||
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
||||||
GiftDiamondRatioPercent: giftDiamondRatioPercent,
|
GiftDiamondRatioPercent: giftDiamondRatioPercent,
|
||||||
|
GiftDiamondRatioPPM: giftDiamondRatioPPM,
|
||||||
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
|
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
|
||||||
HostPeriodCycleKey: hostPeriodCycleKey,
|
HostPeriodCycleKey: hostPeriodCycleKey,
|
||||||
HostPointAssetType: hostPointPolicy.HostPointAsset,
|
HostPointAssetType: hostPointPolicy.HostPointAsset,
|
||||||
@ -309,6 +325,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
AgencyPointAdded: agencyPointAdded,
|
AgencyPointAdded: agencyPointAdded,
|
||||||
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
AgencyPointBalanceAfter: agencyPointBalanceAfter,
|
||||||
AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM,
|
AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM,
|
||||||
|
AgencyPointShareBase: hostPointPolicy.AgencyPointShareBase,
|
||||||
|
AgencyPointShareBaseAmount: agencyPointShareBaseAmount,
|
||||||
PointsPerUSD: hostPointPolicy.PointsPerUSD,
|
PointsPerUSD: hostPointPolicy.PointsPerUSD,
|
||||||
RoomID: command.RoomID,
|
RoomID: command.RoomID,
|
||||||
RoomRegionID: command.RegionID,
|
RoomRegionID: command.RegionID,
|
||||||
@ -431,6 +449,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
return ledger.Receipt{}, err
|
return ledger.Receipt{}, err
|
||||||
}
|
}
|
||||||
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||||
|
if agencyPointState == targetPointState {
|
||||||
|
// owner=self 的两个角色共享一个 POINT 账户;同步回执和 HostPointCredited 必须返回整笔
|
||||||
|
// 交易完成后的最终余额,而不是只完成主播分录时的中间余额。
|
||||||
|
metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount
|
||||||
|
}
|
||||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||||
TransactionID: transactionID,
|
TransactionID: transactionID,
|
||||||
UserID: command.TargetAgencyOwnerUserID,
|
UserID: command.TargetAgencyOwnerUserID,
|
||||||
@ -483,12 +506,29 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
if giftIncomeCoinAmount > 0 {
|
if giftIncomeCoinAmount > 0 {
|
||||||
events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
|
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 {
|
if hostPointAdded > 0 {
|
||||||
|
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, 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))
|
events = append(events, hostPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
|
||||||
}
|
}
|
||||||
if agencyPointAdded > 0 {
|
if agencyPointAdded > 0 {
|
||||||
|
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, 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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,6 +52,7 @@ type giftMetadata struct {
|
|||||||
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
|
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
|
||||||
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
|
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
|
||||||
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
|
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
|
||||||
|
GiftDiamondRatioPPM int64 `json:"gift_diamond_ratio_ppm,omitempty"`
|
||||||
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
|
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
|
||||||
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
|
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
|
||||||
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
|
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
|
||||||
@ -66,6 +67,10 @@ type giftMetadata struct {
|
|||||||
AgencyPointAdded int64 `json:"agency_point_added,omitempty"`
|
AgencyPointAdded int64 `json:"agency_point_added,omitempty"`
|
||||||
AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"`
|
AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"`
|
||||||
AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,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"`
|
PointsPerUSD int64 `json:"points_per_usd,omitempty"`
|
||||||
RoomID string `json:"room_id"`
|
RoomID string `json:"room_id"`
|
||||||
RoomRegionID int64 `json:"room_region_id"`
|
RoomRegionID int64 `json:"room_region_id"`
|
||||||
|
|||||||
@ -834,6 +834,9 @@ func hostPointCreditedEvent(transactionID string, commandID string, metadata gif
|
|||||||
"policy_template_code": metadata.HostPointTemplateCode,
|
"policy_template_code": metadata.HostPointTemplateCode,
|
||||||
"policy_template_version": metadata.HostPointTemplateVersion,
|
"policy_template_version": metadata.HostPointTemplateVersion,
|
||||||
"host_point_ratio_ppm": metadata.HostPointRatioPPM,
|
"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,
|
"points_per_usd": metadata.PointsPerUSD,
|
||||||
"allow_self_brushing": true,
|
"allow_self_brushing": true,
|
||||||
"created_at_ms": nowMs,
|
"created_at_ms": nowMs,
|
||||||
@ -870,6 +873,8 @@ func agencyPointCreditedEvent(transactionID string, commandID string, metadata g
|
|||||||
"policy_template_code": metadata.HostPointTemplateCode,
|
"policy_template_code": metadata.HostPointTemplateCode,
|
||||||
"policy_template_version": metadata.HostPointTemplateVersion,
|
"policy_template_version": metadata.HostPointTemplateVersion,
|
||||||
"agency_point_ratio_ppm": metadata.AgencyPointRatioPPM,
|
"agency_point_ratio_ppm": metadata.AgencyPointRatioPPM,
|
||||||
|
"agency_share_base": metadata.AgencyPointShareBase,
|
||||||
|
"agency_share_base_amount": metadata.AgencyPointShareBaseAmount,
|
||||||
"created_at_ms": nowMs,
|
"created_at_ms": nowMs,
|
||||||
},
|
},
|
||||||
CreatedAtMS: nowMs,
|
CreatedAtMS: nowMs,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -11,6 +12,15 @@ import (
|
|||||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
"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 {
|
type walletPolicyInstance struct {
|
||||||
InstanceCode string
|
InstanceCode string
|
||||||
TemplateCode string
|
TemplateCode string
|
||||||
@ -18,7 +28,9 @@ type walletPolicyInstance struct {
|
|||||||
RegionID int64
|
RegionID int64
|
||||||
HostPointAsset string
|
HostPointAsset string
|
||||||
HostPointRatioPPM int64
|
HostPointRatioPPM int64
|
||||||
|
HostGiftTypeRatioEnabled bool
|
||||||
AgencyPointRatioPPM int64
|
AgencyPointRatioPPM int64
|
||||||
|
AgencyPointShareBase string
|
||||||
PointsPerUSD int64
|
PointsPerUSD int64
|
||||||
WithdrawFeeBPS int64
|
WithdrawFeeBPS int64
|
||||||
}
|
}
|
||||||
@ -30,7 +42,11 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
|||||||
}
|
}
|
||||||
row := tx.QueryRowContext(ctx, `
|
row := tx.QueryRowContext(ctx, `
|
||||||
SELECT instance_code, template_code, template_version, region_id, host_point_asset_type,
|
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
|
FROM wallet_policy_instances
|
||||||
WHERE app_code = ?
|
WHERE app_code = ?
|
||||||
AND region_id IN (?, 0)
|
AND region_id IN (?, 0)
|
||||||
@ -42,7 +58,20 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
|||||||
appCode, regionID, nowMS, nowMS, regionID,
|
appCode, regionID, nowMS, nowMS, regionID,
|
||||||
)
|
)
|
||||||
var item walletPolicyInstance
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return walletPolicyInstance{}, false, nil
|
return walletPolicyInstance{}, false, nil
|
||||||
}
|
}
|
||||||
@ -52,8 +81,33 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
|
|||||||
if item.HostPointAsset == "" {
|
if item.HostPointAsset == "" {
|
||||||
item.HostPointAsset = ledger.AssetPoint
|
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")
|
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
|
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 表示长期有效',
|
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
|
||||||
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
|
||||||
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例,ppm',
|
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 展示换算比例',
|
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
|
||||||
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
|
||||||
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
rule_json JSON NOT NULL COMMENT '完整政策快照',
|
||||||
@ -386,7 +386,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
_, err = db.ExecContext(ctx, `
|
_, err = db.ExecContext(ctx, `
|
||||||
@ -399,14 +399,27 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
|
|||||||
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
point_delta BIGINT NOT NULL,
|
point_delta BIGINT NOT NULL,
|
||||||
ratio_ppm 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 '',
|
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '',
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
|
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_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)
|
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 礼物分成流水'`)
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`)
|
||||||
|
if err != nil {
|
||||||
return err
|
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 隔离的提现币商配置。
|
// ensurePointWithdrawalCoinSellerSchema 创建跨 Fami/Huwaa/Lalu 共用、按 app_code 隔离的提现币商配置。
|
||||||
// 表放在 wallet 数据库,保证兑换时读取比例和写账本处于同一可信服务边界,H5 不能自报比例。
|
// 表放在 wallet 数据库,保证兑换时读取比例和写账本处于同一可信服务边界,H5 不能自报比例。
|
||||||
|
|||||||
@ -459,6 +459,47 @@ func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instance
|
|||||||
r.SeedWalletPolicyInstanceWithRates(appCode, instanceCode, regionID, status, hostPointRatioPPM, agencyPointRatioPPM, 100000, 500)
|
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 提现和兑币测试都从同一份已发布政策读取分母。
|
// SeedWalletPolicyInstanceWithRates 让 POINT 提现和兑币测试都从同一份已发布政策读取分母。
|
||||||
func (r *Repository) SeedWalletPolicyInstanceWithRates(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, pointsPerUSD int64, feeBPS int64) {
|
func (r *Repository) SeedWalletPolicyInstanceWithRates(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, pointsPerUSD int64, feeBPS int64) {
|
||||||
r.t.Helper()
|
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.
|
// SetGiftDiamondRatio configures the gift diamond ratio for a room region and gift type.
|
||||||
func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) {
|
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()
|
r.t.Helper()
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||||
INSERT INTO gift_diamond_ratio_configs (
|
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
|
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
|
ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
ratio_percent = VALUES(ratio_percent),
|
ratio_percent = VALUES(ratio_percent),
|
||||||
updated_at_ms = VALUES(updated_at_ms)`,
|
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 {
|
if err != nil {
|
||||||
r.t.Fatalf("set gift diamond ratio failed: %v", err)
|
r.t.Fatalf("set gift diamond ratio failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user