feat(wallet): support configurable agency gift income sharing

This commit is contained in:
ZuoZuo 2026-07-21 09:50:33 +08:00
parent 5e124361f8
commit 3d4fd6c94d
23 changed files with 1064 additions and 203 deletions

View File

@ -126,8 +126,9 @@ type LuckyGiftMeta struct {
Recharge_7DCoins int64 `protobuf:"varint,19,opt,name=recharge_7d_coins,json=recharge7dCoins,proto3" json:"recharge_7d_coins,omitempty"`
Recharge_30DCoins int64 `protobuf:"varint,20,opt,name=recharge_30d_coins,json=recharge30dCoins,proto3" json:"recharge_30d_coins,omitempty"`
LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的
// host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 本次结算的主播收益:
// POINT 政策读 host_point_added旧链路读 host_period_diamond_addedAgency 额外收益不包含在内。
// 幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"`
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。

View File

@ -40,8 +40,9 @@ message LuckyGiftMeta {
int64 recharge_7d_coins = 19;
int64 recharge_30d_coins = 20;
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet
// host_period_diamond_added owner
// gift_income_coins wallet
// POINT host_point_added host_period_diamond_addedAgency
// owner
int64 gift_income_coins = 22;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close

View File

@ -41,6 +41,7 @@ INCREMENTAL_MIGRATION_FILES=(
"services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql"
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
"services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql"
)
# Keep MySQL as the only required dependency for this script. Business services

View File

@ -26,6 +26,9 @@ const (
policyTaskRewardAssetCoin = "COIN"
agencyShareBaseChargeAmount = "charge_amount"
agencyShareBaseHostIncome = "host_income"
defaultPolicyTemplateCode = "first_google70000_coin_seller_92000_100000_v1"
defaultPolicyTemplateVersion = "v1"
)
@ -64,12 +67,14 @@ type policyInstanceRow struct {
}
type compiledPolicy struct {
HostPointRatioPPM int64
AgencyPointRatioPPM int64
PointsPerUSD int64
WithdrawFeeBPS int64
TaskRewardAssetType string
RuleJSON json.RawMessage
HostPointRatioPPM int64
HostUseGiftTypeRatio bool
AgencyPointRatioPPM int64
AgencyShareBase string
PointsPerUSD int64
WithdrawFeeBPS int64
TaskRewardAssetType string
RuleJSON json.RawMessage
}
func NewService(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, activity activityclient.Client) *Service {
@ -565,7 +570,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner POINT 分成比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例计算基数由 rule_json agency.share_base 决定ppm',
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
rule_json JSON NOT NULL COMMENT '完整政策快照',
@ -580,7 +585,7 @@ func ensureWalletPolicyRuntimeTable(ctx context.Context, db *sql.DB) error {
return err
}
// 运行库可能是旧版本建表;幂等补列保证发布新策略时不会因列缺失中断。
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
return nil
@ -650,30 +655,76 @@ func compilePolicy(raw json.RawMessage) (compiledPolicy, error) {
pointsPerUSD := int64FromJSON(decoded["points_per_usd"], 100000)
host, _ := decoded["host"].(map[string]any)
hostRatioPercent := floatFromJSON(host["point_ratio_percent"], 70)
// 礼物类型倍率是 wallet 解释完整 rule_json 时使用的开关Admin 只负责拒绝字符串/数字等歧义配置。
hostUseGiftTypeRatio, err := optionalBoolFromJSON(host, "use_gift_type_ratio", false)
if err != nil {
return compiledPolicy{}, errors.New("rule_json host use_gift_type_ratio must be boolean")
}
agency, _ := decoded["agency"].(map[string]any)
// Agency 比例由跨 App 共用的策略模板显式配置;旧模板缺字段时保持 0避免发布动作隐式改变存量 App 账务。
agencyRatioPercent := floatFromJSON(agency["point_ratio_percent"], 0)
// charge_amount 延续旧策略的守恒分账host_income 是平台在主播实际 POINT 收益之上追加,不能套用二者合计不超过 100% 的限制。
agencyShareBase, err := agencyShareBaseFromJSON(agency)
if err != nil {
return compiledPolicy{}, err
}
withdrawFeeBPS := int64FromJSON(host["withdraw_fee_bps"], 500)
ratioPPM := int64(math.Round(hostRatioPercent * 10000))
agencyRatioPPM := int64(math.Round(agencyRatioPercent * 10000))
tasks, _ := decoded["tasks"].(map[string]any)
taskRewardAssetType := strings.ToUpper(strings.TrimSpace(stringFromJSON(tasks["reward_asset_type"], policyTaskRewardAssetCoin)))
if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || ratioPPM+agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 {
if pointsPerUSD <= 0 || ratioPPM <= 0 || ratioPPM > 1000000 || agencyRatioPPM < 0 || agencyRatioPPM > 1000000 || withdrawFeeBPS < 0 || withdrawFeeBPS > 10000 {
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
}
if agencyShareBase == agencyShareBaseChargeAmount && ratioPPM+agencyRatioPPM > 1000000 {
return compiledPolicy{}, errors.New("rule_json host point policy is invalid")
}
if taskRewardAssetType != policyTaskRewardAssetCoin && taskRewardAssetType != "POINT" {
return compiledPolicy{}, errors.New("rule_json task reward asset_type is invalid")
}
return compiledPolicy{
HostPointRatioPPM: ratioPPM,
AgencyPointRatioPPM: agencyRatioPPM,
PointsPerUSD: pointsPerUSD,
WithdrawFeeBPS: withdrawFeeBPS,
TaskRewardAssetType: taskRewardAssetType,
RuleJSON: raw,
HostPointRatioPPM: ratioPPM,
HostUseGiftTypeRatio: hostUseGiftTypeRatio,
AgencyPointRatioPPM: agencyRatioPPM,
AgencyShareBase: agencyShareBase,
PointsPerUSD: pointsPerUSD,
WithdrawFeeBPS: withdrawFeeBPS,
TaskRewardAssetType: taskRewardAssetType,
// 不为编译字段增设运行表列owner service 必须收到完整规则快照,才能按礼物类型和收益基数解释账务。
RuleJSON: raw,
}, nil
}
func agencyShareBaseFromJSON(agency map[string]any) (string, error) {
value, exists := agency["share_base"]
if !exists {
return agencyShareBaseChargeAmount, nil
}
shareBase, ok := value.(string)
if !ok {
return "", errors.New("rule_json agency share_base is invalid")
}
shareBase = strings.ToLower(strings.TrimSpace(shareBase))
switch shareBase {
case agencyShareBaseChargeAmount, agencyShareBaseHostIncome:
return shareBase, nil
default:
return "", errors.New("rule_json agency share_base is invalid")
}
}
func optionalBoolFromJSON(object map[string]any, key string, fallback bool) (bool, error) {
value, exists := object[key]
if !exists {
return fallback, nil
}
parsed, ok := value.(bool)
if !ok {
return false, errors.New("JSON value is not boolean")
}
return parsed, nil
}
func scanTemplate(row interface{ Scan(dest ...any) error }) (templateDTO, error) {
var item templateDTO
var rule string

View File

@ -205,6 +205,9 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin
if fami.AgencyPointRatioPPM != 0 {
t.Fatalf("legacy policy without agency section must remain zero: %+v", fami)
}
if fami.AgencyShareBase != agencyShareBaseChargeAmount || fami.HostUseGiftTypeRatio {
t.Fatalf("legacy policy must keep charge_amount base and disabled gift type ratio: %+v", fami)
}
configured, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70},"agency":{"point_ratio_percent":20}}`))
if err != nil {
t.Fatalf("compile configured policy failed: %v", err)
@ -212,6 +215,46 @@ func TestCompilePolicyUsesExplicitAgencyRatioAndKeepsLegacyDefaultZero(t *testin
if configured.AgencyPointRatioPPM != 200000 {
t.Fatalf("configured agency ratio mismatch: %+v", configured)
}
if configured.AgencyShareBase != agencyShareBaseChargeAmount {
t.Fatalf("missing agency share_base must use legacy charge_amount semantics: %+v", configured)
}
}
func TestCompilePolicyRejectsChargeAmountOverAllocation(t *testing.T) {
for name, ruleJSON := range map[string]json.RawMessage{
"legacy default": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20}}`),
"explicit base": json.RawMessage(`{"host":{"point_ratio_percent":100},"agency":{"point_ratio_percent":20,"share_base":"charge_amount"}}`),
} {
t.Run(name, func(t *testing.T) {
if _, err := compilePolicy(ruleJSON); err == nil {
t.Fatal("charge_amount policy must reject host and agency allocations above 100 percent")
}
})
}
}
func TestCompilePolicyAllowsHostIncomeBonusAndParsesFlags(t *testing.T) {
ruleJSON := json.RawMessage(`{"host":{"point_ratio_percent":100,"use_gift_type_ratio":true},"agency":{"point_ratio_percent":20,"share_base":"host_income"}}`)
compiled, err := compilePolicy(ruleJSON)
if err != nil {
t.Fatalf("compile host_income policy failed: %v", err)
}
if compiled.HostPointRatioPPM != 1000000 || compiled.AgencyPointRatioPPM != 200000 {
t.Fatalf("host_income ratios mismatch: %+v", compiled)
}
if compiled.AgencyShareBase != agencyShareBaseHostIncome || !compiled.HostUseGiftTypeRatio {
t.Fatalf("host_income flags mismatch: %+v", compiled)
}
if string(compiled.RuleJSON) != string(ruleJSON) {
t.Fatalf("wallet owner snapshot must keep the complete source rule_json: got %s", compiled.RuleJSON)
}
}
func TestCompilePolicyRejectsNonBooleanGiftTypeRatioFlag(t *testing.T) {
_, err := compilePolicy(json.RawMessage(`{"host":{"point_ratio_percent":70,"use_gift_type_ratio":"true"}}`))
if err == nil {
t.Fatal("use_gift_type_ratio must reject non-boolean JSON values")
}
}
type fakePolicyActivityClient struct {

View File

@ -2,30 +2,50 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
-- 收益策略模板是 Lalu/Huwaa/Fami 共用的产品配置入口。存量模板显式补 0避免旧实例重发时产生新分成。
-- 收益策略模板是 Lalu/Huwaa/Fami 共用的产品配置入口。默认文档放在 merge 左侧,既能创建 agency 对象,
-- 又不会覆盖已存在的配置;旧实例重发仍保持 0 分成和 charge_amount 守恒口径。
UPDATE admin_policy_templates
SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0),
SET rule_json = JSON_MERGE_PATCH(
CAST('{"agency":{"point_ratio_percent":0,"share_base":"charge_amount"}}' AS JSON),
rule_json
),
updated_at_ms = @now_ms
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
AND template_version = 'v1'
AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL;
AND (JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL
OR JSON_EXTRACT(rule_json, '$.agency.share_base') IS NULL);
UPDATE admin_policy_template_versions
SET rule_json = JSON_SET(rule_json, '$.agency.point_ratio_percent', 0),
SET rule_json = JSON_MERGE_PATCH(
CAST('{"agency":{"point_ratio_percent":0,"share_base":"charge_amount"}}' AS JSON),
rule_json
),
updated_at_ms = @now_ms
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1'
AND template_version = 'v1'
AND JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL;
AND (JSON_EXTRACT(rule_json, '$.agency.point_ratio_percent') IS NULL
OR JSON_EXTRACT(rule_json, '$.agency.share_base') IS NULL);
-- Fami 从同一份公共政策结构派生独立模板,默认 20%;运营可在 Admin 编辑并发布,不需要改代码。
-- Fami 的礼物类型先决定主播实际 POINT 收益,再由平台按该收益额外给 Agency owner 20%。内层 JSON_SET
-- 先确保 host/agency 父对象存在,外层再写嵌套字段,避免 MySQL 对不存在父路径静默忽略,同时保留其他规则。
INSERT INTO admin_policy_templates (
template_code, template_version, name, status, rule_json, description,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
)
SELECT
'fami_guild_revenue_policy', 'v1', 'Fami 公会收益政策', 'active',
JSON_SET(rule_json, '$.agency.point_ratio_percent', 20),
'Fami 主播与 Agency POINT 收益政策Agency 默认 20%,以发布后的运行快照为准。',
JSON_SET(
JSON_SET(
rule_json,
'$.host', COALESCE(JSON_EXTRACT(rule_json, '$.host'), JSON_OBJECT()),
'$.agency', COALESCE(JSON_EXTRACT(rule_json, '$.agency'), JSON_OBJECT())
),
'$.host.point_ratio_percent', 100,
'$.host.use_gift_type_ratio', JSON_EXTRACT('true', '$'),
'$.agency.point_ratio_percent', 20,
'$.agency.share_base', 'host_income'
),
'Fami 主播 POINT 收益政策Agency 按主播实际 POINT 收益由平台额外发放 20%,以发布后的运行快照为准。',
0, 0, @now_ms, @now_ms
FROM admin_policy_templates
WHERE template_code = 'first_google70000_coin_seller_92000_100000_v1' AND template_version = 'v1'

View File

@ -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';

View File

@ -213,7 +213,8 @@ type DrawCommand struct {
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
// HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。
// HostPeriodDiamondAdded 是兼容字段名,实际承载 wallet 已落账的主播收益POINT 或周期钻石);
// Agency 额外收益不进入幸运礼物资金拆分,服务也不能自行重算主播比例。
HostPeriodDiamondAdded int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string

View File

@ -129,12 +129,25 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台
// “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。
GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(),
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 实际给主播
// 结算的收益:新 POINT 政策读 host_point_added旧工资钻石链路读 host_period_diamond_added。
// Agency 额外 20% 不属于主播收益,绝不能混入幸运礼物公共池/平台利润的守恒拆分。
GiftIncomeCoins: walletHostGiftIncome(targetBilling.Billing),
}, nil
}
// walletHostGiftIncome 收敛 wallet 两代主播收益字段。两个字段在 wallet 事务里互斥;优先读 POINT
// 让 Fami 收益政策参与幸运礼物资金拆分,回退周期钻石则保持 Lalu 与旧回执的滚动发布兼容。
func walletHostGiftIncome(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil {
return 0
}
if billing.GetHostPointAdded() > 0 {
return billing.GetHostPointAdded()
}
return billing.GetHostPeriodDiamondAdded()
}
func normalizeLuckyGiftStrategyVersion(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {

View File

@ -127,13 +127,15 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
HostPointAdded: 7,
},
{
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
HostPeriodDiamondAdded: 1,
},
}}
luckyGift := &luckyGiftTestClient{}
@ -194,6 +196,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-auth-session-1" ||
drawMeta.GetAnchorId() != "101" ||
drawMeta.GetGiftIncomeCoins() != 7 ||
drawMeta.GetVisibleRegionId() != 9001 ||
drawMeta.GetSenderName() != "lucky sender" ||
drawMeta.GetSenderAvatar() != "https://cdn.example.com/sender.png" ||
@ -218,6 +221,9 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
if legacyDraw.GetDeviceId() != "" {
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId())
}
if legacyDraw.GetGiftIncomeCoins() != 1 {
t.Fatalf("legacy host-period income must remain the lucky fund split owner fact: %+v", legacyDraw)
}
luckyGift.checkStrategy = "dynamic_v3"
debitsBeforeDynamicReject := len(wallet.debitRequests)

View File

@ -181,7 +181,7 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances (
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定ppm',
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
rule_json JSON NOT NULL COMMENT '完整政策快照',
@ -220,7 +220,9 @@ CREATE TABLE IF NOT EXISTS agency_point_share_entries (
sender_user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID私聊送礼为空',
point_delta BIGINT NOT NULL COMMENT 'Agency owner 本次 POINT 分成',
ratio_ppm BIGINT NOT NULL COMMENT 'Agency 分成比例快照',
ratio_ppm BIGINT NOT NULL COMMENT 'Agency 分成比例快照,计算基数见 share_base_type',
share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数unknown/charge_amount/host_incomeunknown 表示旧写入方未提供快照',
share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数share_base_type=unknown 时不可解释为真实 0',
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '' COMMENT '收益策略实例快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),

View File

@ -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 实际策略一致。

View File

@ -171,40 +171,340 @@ func TestHuwaaHostGiftPolicyCreditsPointWithoutCoinIncomeAndAllowsSelfGift(t *te
func TestFamiHostGiftPolicyCreditsAgencyShareWithHistoricalOwnershipSnapshot(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("fami", 32001, 1000)
repository.SetGiftPriceForApp("fami", "fami-rose", "v1", 100, 0, 100)
repository.SeedWalletPolicyInstanceWithAgency("fami", "fami-guild-live", 0, "active", 700000, 200000)
// Fami 公会收益先按礼物类型计算主播实收,再额外给 Agency owner 主播实收的 20%
// 该公式允许一次礼物产生 100% 主播收益和额外 20% 公会收益,不能套用旧的总资金池切分语义。
repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income")
svc := walletservice.New(repository)
tests := []struct {
name string
giftType string
giftID string
commandID string
senderID int64
hostID int64
agencyID int64
wantHost int64
wantAgency int64
wantRatio string
wantPPM int64
}{
{name: "normal", giftType: resourcedomain.GiftTypeNormal, giftID: "fami-normal-rose", commandID: "cmd-fami-agency-normal", senderID: 32001, hostID: 32002, agencyID: 32003, wantHost: 10_000, wantAgency: 2_000, wantRatio: "100.00", wantPPM: 1_000_000},
{name: "lucky", giftType: resourcedomain.GiftTypeLucky, giftID: "fami-lucky-rose", commandID: "cmd-fami-agency-lucky", senderID: 32011, hostID: 32012, agencyID: 32013, wantHost: 1_000, wantAgency: 200, wantRatio: "10.00", wantPPM: 100_000},
{name: "super_lucky", giftType: resourcedomain.GiftTypeSuperLucky, giftID: "fami-super-lucky-rose", commandID: "cmd-fami-agency-super-lucky", senderID: 32021, hostID: 32022, agencyID: 32023, wantHost: 100, wantAgency: 20, wantRatio: "1.00", wantPPM: 10_000},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository.SetBalanceForApp("fami", test.senderID, 10_000)
repository.SetGiftPriceForApp("fami", test.giftID, "v1", 10_000, 0, 10_000)
repository.SetGiftType(test.giftID, test.giftType)
// 区域值刻意与全局 fallback 区分,验证 POINT 政策把真正命中的第一阶段倍率来源一并冻结。
repository.SetGiftDiamondRatioForApp("fami", 12, test.giftType, test.wantRatio)
command := ledger.DebitGiftCommand{
AppCode: "fami", CommandID: test.commandID, RoomID: "room-fami",
SenderUserID: test.senderID, TargetUserID: test.hostID, TargetIsHost: true, TargetHostRegionID: 12,
TargetAgencyOwnerUserID: test.agencyID, GiftID: test.giftID, GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
}
receipt, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("DebitGift with Fami %s agency policy failed: %v", test.giftType, err)
}
if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != test.wantHost || receipt.HostPointBalanceAfter != test.wantHost ||
receipt.HostPointAssetType != ledger.AssetPoint || receipt.HostPointPolicyInstanceCode != "fami-guild-live" {
t.Fatalf("Fami %s host point receipt mismatch: %+v", test.giftType, receipt)
}
assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost)
assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency)
// 流水必须冻结送礼时的主播与 owner 归属、公式名称和实际主播收入;以后成员转会不能反推改写历史收益。
shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?"
shareArgs := []any{receipt.TransactionID, test.hostID, test.agencyID, test.wantAgency, int64(200_000), "host_income", test.wantHost}
if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 {
t.Fatalf("Fami %s agency ownership/formula snapshot mismatch, got %d", test.giftType, got)
}
// POINT 政策也必须冻结第一阶段礼物倍率;只记录最终 host_income 无法解释未来配置变更前的计算过程。
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12", receipt.TransactionID, test.wantRatio, test.wantPPM); got != 1 {
t.Fatalf("Fami %s gift-type ratio transaction snapshot mismatch, got %d", test.giftType, got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 {
t.Fatalf("Fami %s host credit must publish one role fact, got %d", test.giftType, got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_percent')) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_ppm')) AS SIGNED) = ?", receipt.TransactionID, "HostPointCredited", test.wantRatio, test.wantPPM); got != 1 {
t.Fatalf("Fami %s gift-type ratio role fact mismatch, got %d", test.giftType, got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 {
t.Fatalf("Fami %s agency share must publish one role fact, got %d", test.giftType, got)
}
// 同一命令重放只读首次交易快照,不重新计算礼物类型比例,也不重复写 owner 收益与角色事实。
replayed, err := svc.DebitGift(context.Background(), command)
if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointAdded != receipt.HostPointAdded {
t.Fatalf("Fami %s agency share replay mismatch: receipt=%+v err=%v", test.giftType, replayed, err)
}
assertBalanceForApp(t, svc, "fami", test.hostID, ledger.AssetPoint, test.wantHost)
assertBalanceForApp(t, svc, "fami", test.agencyID, ledger.AssetPoint, test.wantAgency)
if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 {
t.Fatalf("Fami %s replay duplicated or changed owner snapshot, got %d", test.giftType, got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", receipt.TransactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 {
t.Fatalf("Fami %s replay duplicated role facts, got %d", test.giftType, got)
}
})
}
}
// TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact 验证公会长本人收礼时仍保留主播和 Agency 两套账务角色,
// 但同一 POINT 账户只能发布一条合并后的余额事实,避免下游按两条 balance event 覆盖最终余额。
func TestFamiAgencyOwnerSelfGiftMergesPointBalanceFact(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("fami", 33001, 10_000)
repository.SetGiftPriceForApp("fami", "fami-owner-self-normal", "v1", 10_000, 0, 10_000)
repository.SetGiftType("fami-owner-self-normal", resourcedomain.GiftTypeNormal)
repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income")
svc := walletservice.New(repository)
command := ledger.DebitGiftCommand{
AppCode: "fami", CommandID: "cmd-fami-owner-self-direct", DirectGift: true,
SenderUserID: 33001, TargetUserID: 33002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 33002,
GiftID: "fami-owner-self-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
}
receipt, err := svc.DebitGift(context.Background(), command)
if err != nil {
t.Fatalf("DebitGift with self-owned Fami agency failed: %v", err)
}
if receipt.CoinSpent != 10_000 || receipt.HostPointAdded != 10_000 || receipt.HostPointBalanceAfter != 12_000 {
t.Fatalf("self-owned Fami receipt mismatch: %+v", receipt)
}
assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000)
if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND status = ?", receipt.TransactionID, "direct_gift_debit", "succeeded"); got != 1 {
t.Fatalf("self-owned direct gift transaction must succeed exactly once, got %d", got)
}
// 主播 10000 与 Agency 2000 是两条可审计分录;账户级余额事实则聚合成 12000只允许一条。
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 {
t.Fatalf("self-owned gift must preserve two role ledger entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 {
t.Fatalf("self-owned POINT account must publish one merged balance fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint, int64(12_000)); got != 1 {
t.Fatalf("self-owned POINT balance fact must carry merged +12000 delta, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPointCredited"); got != 1 {
t.Fatalf("self-owned gift must retain one HostPointCredited role fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 {
t.Fatalf("self-owned gift must retain one AgencyPointCredited role fact, got %d", got)
}
shareWhere := "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ? AND share_base_type = ? AND share_base_amount = ?"
shareArgs := []any{receipt.TransactionID, int64(33002), int64(33002), int64(2_000), int64(200_000), "host_income", int64(10_000)}
if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 {
t.Fatalf("self-owned agency share snapshot mismatch, got %d", got)
}
replayed, err := svc.DebitGift(context.Background(), command)
if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.HostPointBalanceAfter != 12_000 {
t.Fatalf("self-owned Fami gift replay mismatch: receipt=%+v err=%v", replayed, err)
}
assertBalanceForApp(t, svc, "fami", 33002, ledger.AssetPoint, 12_000)
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(33002), ledger.AssetPoint); got != 2 {
t.Fatalf("self-owned replay duplicated role entries, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, "WalletBalanceChanged", int64(33002), ledger.AssetPoint); got != 1 {
t.Fatalf("self-owned replay duplicated merged balance fact, got %d", got)
}
if got := repository.CountRows("agency_point_share_entries", shareWhere, shareArgs...); got != 1 {
t.Fatalf("self-owned replay duplicated share row, got %d", got)
}
}
// TestFamiBatchGiftKeepsIndependentAgencyRoleFacts 验证批量事务按 target 独立固化主播/Agency 角色:
// 一个 target 是 owner 本人,另一个是旗下主播,两者共享 owner 账户也不能互相覆盖或重复投递。
func TestFamiBatchGiftKeepsIndependentAgencyRoleFacts(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("fami", 34001, 20_000)
repository.SetGiftPriceForApp("fami", "fami-batch-normal", "v1", 10_000, 0, 10_000)
repository.SetGiftType("fami-batch-normal", resourcedomain.GiftTypeNormal)
repository.SeedWalletGiftRevenuePolicy("fami", "fami-guild-live", 0, "active", 1_000_000, 200_000, true, "host_income")
svc := walletservice.New(repository)
command := ledger.BatchDebitGiftCommand{
AppCode: "fami", CommandID: "cmd-fami-agency-batch", RoomID: "room-fami-batch",
SenderUserID: 34001, GiftID: "fami-batch-normal", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
Targets: []ledger.DebitGiftTargetCommand{
{CommandID: "cmd-fami-agency-batch:target:34002", TargetUserID: 34002, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002},
{CommandID: "cmd-fami-agency-batch:target:34003", TargetUserID: 34003, TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 34002},
},
}
receipt, err := svc.BatchDebitGift(context.Background(), command)
if err != nil {
t.Fatalf("BatchDebitGift with mixed Fami agency targets failed: %v", err)
}
if len(receipt.Targets) != 2 || receipt.Aggregate.CoinSpent != 20_000 || receipt.Aggregate.HostPointAdded != 20_000 {
t.Fatalf("mixed Fami agency batch aggregate mismatch: %+v", receipt)
}
assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0)
assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000)
assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000)
receiptsByTarget := make(map[int64]ledger.Receipt, len(receipt.Targets))
for _, target := range receipt.Targets {
receiptsByTarget[target.TargetUserID] = target.Receipt
}
selfReceipt, selfOK := receiptsByTarget[34002]
memberReceipt, memberOK := receiptsByTarget[34003]
if !selfOK || !memberOK || selfReceipt.TransactionID == "" || memberReceipt.TransactionID == "" {
t.Fatalf("batch must return one stable receipt per target: %+v", receipt.Targets)
}
if selfReceipt.HostPointBalanceAfter != 12_000 {
t.Fatalf("batch self-owner receipt must expose final shared POINT balance: %+v", selfReceipt)
}
for _, targetReceipt := range []struct {
name string
hostID int64
receipt ledger.Receipt
}{
{name: "self_owner", hostID: 34002, receipt: selfReceipt},
{name: "member", hostID: 34003, receipt: memberReceipt},
} {
if targetReceipt.receipt.HostPointAdded != 10_000 {
t.Fatalf("%s target host credit mismatch: %+v", targetReceipt.name, targetReceipt.receipt)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "HostPointCredited"); got != 1 {
t.Fatalf("%s target must publish one host role fact, got %d", targetReceipt.name, got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", targetReceipt.receipt.TransactionID, "AgencyPointCredited"); got != 1 {
t.Fatalf("%s target must publish one agency role fact, got %d", targetReceipt.name, got)
}
if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", targetReceipt.receipt.TransactionID, targetReceipt.hostID, int64(34002), int64(2_000), "host_income", int64(10_000)); got != 1 {
t.Fatalf("%s target agency snapshot mismatch, got %d", targetReceipt.name, got)
}
}
// Self target 的两种角色落在同一账户,只能产生 +12000 一条余额事实;成员 target 则分别通知成员与 owner。
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", selfReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 1 {
t.Fatalf("batch self target must publish one merged POINT balance fact, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", selfReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(12_000)); got != 1 {
t.Fatalf("batch self target merged POINT delta mismatch, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND asset_type = ?", memberReceipt.TransactionID, "WalletBalanceChanged", ledger.AssetPoint); got != 2 {
t.Fatalf("batch member target must publish separate host/owner balance facts, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34003), ledger.AssetPoint, int64(10_000)); got != 1 {
t.Fatalf("batch member host POINT balance fact mismatch, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", memberReceipt.TransactionID, "WalletBalanceChanged", int64(34002), ledger.AssetPoint, int64(2_000)); got != 1 {
t.Fatalf("batch member agency POINT balance fact mismatch, got %d", got)
}
// 批量重放按两个 child command 的原交易返回,不重复扣 sender、不重复增加共享 owner也不重发任一 target 的角色事实。
replayed, err := svc.BatchDebitGift(context.Background(), command)
if err != nil {
t.Fatalf("mixed Fami agency batch replay failed: %v", err)
}
if len(replayed.Targets) != 2 || replayed.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || replayed.Targets[1].Receipt.TransactionID != receipt.Targets[1].Receipt.TransactionID || replayed.Targets[0].Receipt.HostPointBalanceAfter != 12_000 {
t.Fatalf("mixed Fami agency batch replay transaction mismatch: first=%+v replay=%+v", receipt.Targets, replayed.Targets)
}
assertBalanceForApp(t, svc, "fami", 34001, ledger.AssetCoin, 0)
assertBalanceForApp(t, svc, "fami", 34002, ledger.AssetPoint, 14_000)
assertBalanceForApp(t, svc, "fami", 34003, ledger.AssetPoint, 10_000)
if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", command.Targets[0].CommandID, command.Targets[1].CommandID); got != 2 {
t.Fatalf("mixed Fami agency batch replay duplicated child transactions, got %d", got)
}
if got := repository.CountRows("agency_point_share_entries", "transaction_id IN (?, ?)", selfReceipt.TransactionID, memberReceipt.TransactionID); got != 2 {
t.Fatalf("mixed Fami agency batch replay duplicated share rows, got %d", got)
}
for _, transactionID := range []string{selfReceipt.TransactionID, memberReceipt.TransactionID} {
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type IN (?, ?)", transactionID, "HostPointCredited", "AgencyPointCredited"); got != 2 {
t.Fatalf("mixed Fami agency batch replay duplicated role facts for transaction %s: got %d", transactionID, got)
}
}
}
// TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero 固化旧 charge_amount 策略的极小额边界:
// host 与 Agency 各自向下取整Agency 为正数时必须独立锁账户,不能依赖 host 也大于 0。
func TestBatchGiftCreditsAgencyWhenHostAmountRoundsToZero(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("fami", 35001, 2)
repository.SetGiftPriceForApp("fami", "fami-batch-agency-rounding", "v1", 2, 0, 2)
repository.SeedWalletGiftRevenuePolicy("fami", "fami-legacy-rounding", 0, "active", 1, 999_999, false, "charge_amount")
svc := walletservice.New(repository)
command := ledger.BatchDebitGiftCommand{
AppCode: "fami", CommandID: "cmd-fami-batch-agency-rounding", RoomID: "room-fami-rounding",
SenderUserID: 35001, GiftID: "fami-batch-agency-rounding", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
Targets: []ledger.DebitGiftTargetCommand{{
CommandID: "cmd-fami-batch-agency-rounding:target:35002", TargetUserID: 35002,
TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 35003,
}},
}
receipt, err := svc.BatchDebitGift(context.Background(), command)
if err != nil {
t.Fatalf("BatchDebitGift agency-only rounding failed: %v", err)
}
if len(receipt.Targets) != 1 || receipt.Targets[0].Receipt.HostPointAdded != 0 {
t.Fatalf("agency-only rounding receipt mismatch: %+v", receipt)
}
assertBalanceForApp(t, svc, "fami", 35002, ledger.AssetPoint, 0)
assertBalanceForApp(t, svc, "fami", 35003, ledger.AssetPoint, 1)
transactionID := receipt.Targets[0].Receipt.TransactionID
if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND point_delta = ? AND share_base_type = ? AND share_base_amount = ?", transactionID, int64(1), "charge_amount", int64(2)); got != 1 {
t.Fatalf("agency-only rounding audit snapshot mismatch, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND user_id = ? AND available_delta = ?", transactionID, "AgencyPointCredited", int64(35003), int64(1)); got != 1 {
t.Fatalf("agency-only rounding role fact mismatch, got %d", got)
}
}
// TestGiftRevenuePolicyIsReusableAcrossApps 证明收益模块不依赖 Fami 分支:
// 任意 App 只要发布同一策略和区域礼物倍率Direct/Batch 都应复用同一结算实现。
func TestGiftRevenuePolicyIsReusableAcrossApps(t *testing.T) {
const appCode = "next-app"
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp(appCode, 36001, 10_000)
repository.SetGiftPriceForApp(appCode, "next-app-lucky", "v1", 10_000, 0, 10_000)
repository.SetGiftType("next-app-lucky", resourcedomain.GiftTypeLucky)
repository.SetGiftDiamondRatioForApp(appCode, 12, resourcedomain.GiftTypeLucky, "25.00")
repository.SeedWalletGiftRevenuePolicy(appCode, "shared-agency-host-income", 12, "active", 1_000_000, 200_000, true, "host_income")
svc := walletservice.New(repository)
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
AppCode: appCode, CommandID: "cmd-next-app-agency-lucky", RoomID: "room-next-app",
SenderUserID: 36001, TargetUserID: 36002, TargetIsHost: true,
TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36003,
GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
})
if err != nil {
t.Fatalf("DebitGift with Fami agency policy failed: %v", err)
t.Fatalf("cross-app gift revenue policy failed: %v", err)
}
if receipt.HostPointAdded != 70 || receipt.HostPointBalanceAfter != 70 {
t.Fatalf("Fami host point receipt mismatch: %+v", receipt)
if receipt.HostPointAdded != 2_500 || receipt.HostPointBalanceAfter != 2_500 || receipt.HostPointPolicyInstanceCode != "shared-agency-host-income" {
t.Fatalf("cross-app host revenue mismatch: %+v", receipt)
}
assertBalanceForApp(t, svc, "fami", 32002, ledger.AssetPoint, 70)
assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20)
if got := repository.CountRows("agency_point_share_entries", "transaction_id = ? AND host_user_id = ? AND agency_owner_user_id = ? AND point_delta = ? AND ratio_ppm = ?", receipt.TransactionID, int64(32002), int64(32003), int64(20), int64(200000)); got != 1 {
t.Fatalf("Fami agency share snapshot mismatch, got %d", got)
}
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "AgencyPointCredited"); got != 1 {
t.Fatalf("Fami agency share must publish one event, got %d", got)
assertBalanceForApp(t, svc, appCode, 36002, ledger.AssetPoint, 2_500)
assertBalanceForApp(t, svc, appCode, 36003, ledger.AssetPoint, 500)
if got := repository.CountRows("wallet_transactions", "app_code = ? AND transaction_id = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_region_id')) AS SIGNED) = 12 AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 250000", appCode, receipt.TransactionID); got != 1 {
t.Fatalf("cross-app effective-region ratio snapshot mismatch, got %d", got)
}
replayed, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
AppCode: "fami", CommandID: "cmd-fami-agency-share", RoomID: "room-fami",
SenderUserID: 32001, TargetUserID: 32002, TargetIsHost: true, TargetHostRegionID: 12,
TargetAgencyOwnerUserID: 32003, GiftID: "fami-rose", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
repository.SetBalanceForApp(appCode, 36011, 10_000)
batch, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
AppCode: appCode, CommandID: "cmd-next-app-agency-lucky-batch", RoomID: "room-next-app",
SenderUserID: 36011, GiftID: "next-app-lucky", GiftCount: 1, PriceVersion: "v1", RegionID: 12, SenderRegionID: 12,
Targets: []ledger.DebitGiftTargetCommand{{
CommandID: "cmd-next-app-agency-lucky-batch:target:36012", TargetUserID: 36012,
TargetIsHost: true, TargetHostRegionID: 12, TargetAgencyOwnerUserID: 36013,
}},
})
if err != nil || replayed.TransactionID != receipt.TransactionID {
t.Fatalf("Fami agency share replay mismatch: receipt=%+v err=%v", replayed, err)
if err != nil {
t.Fatalf("cross-app batch gift revenue policy failed: %v", err)
}
assertBalanceForApp(t, svc, "fami", 32003, ledger.AssetPoint, 20)
if len(batch.Targets) != 1 || batch.Targets[0].Receipt.HostPointAdded != 2_500 {
t.Fatalf("cross-app batch host revenue mismatch: %+v", batch)
}
assertBalanceForApp(t, svc, appCode, 36012, ledger.AssetPoint, 2_500)
assertBalanceForApp(t, svc, appCode, 36013, ledger.AssetPoint, 500)
}
func TestHostGiftWithoutActiveHuwaaPolicyKeepsLegacyCoinIncome(t *testing.T) {

View File

@ -16,10 +16,12 @@ func (r *Repository) insertAgencyPointShare(ctx context.Context, tx *sql.Tx, tra
_, err := tx.ExecContext(ctx, `
INSERT INTO agency_point_share_entries (
app_code, transaction_id, host_user_id, agency_owner_user_id, sender_user_id,
room_id, point_delta, ratio_ppm, policy_instance_code, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
room_id, point_delta, ratio_ppm, share_base_type, share_base_amount,
policy_instance_code, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), transactionID, metadata.TargetUserID, metadata.TargetAgencyOwnerUserID,
metadata.SenderUserID, metadata.RoomID, metadata.AgencyPointAdded, metadata.AgencyPointRatioPPM,
metadata.AgencyPointShareBase, metadata.AgencyPointShareBaseAmount,
metadata.HostPointPolicyInstanceCode, nowMS,
)
return err

View File

@ -118,13 +118,37 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
type resolvedHostDiamondRatio struct {
ratio giftDiamondRatioSnapshot
effectiveRegionID int64
}
// 同一批目标可能跨主播区域。POINT 新政策与旧周期钻石都必须消费相同的 owner 比例解析器;
// 以 region_id 缓存可避免为每个 target 重复查询,同时保留区域缺失时回退全局配置的历史行为。
hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio)
resolveHostRatio := func(regionID int64) (resolvedHostDiamondRatio, error) {
if resolved, exists := hostRatiosByRegion[regionID]; exists {
return resolved, nil
}
ratio, effectiveRegionID, resolveErr := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, regionID, giftConfig.GiftTypeCode)
if resolveErr != nil {
return resolvedHostDiamondRatio{}, resolveErr
}
resolved := resolvedHostDiamondRatio{ratio: ratio, effectiveRegionID: effectiveRegionID}
hostRatiosByRegion[regionID] = resolved
return resolved, nil
}
type targetHostPointPolicy struct {
policy walletPolicyInstance
amount int64
userID int64
agencyAmount int64
agencyOwnerUserID int64
active bool
policy walletPolicyInstance
amount int64
userID int64
giftRatioPercent string
giftRatioPPM int64
giftRatioRegionID int64
agencyAmount int64
agencyShareBaseAmount int64
agencyOwnerUserID int64
active bool
}
hostPointPolicies := make(map[string]targetHostPointPolicy, len(command.Targets))
if chargeSource != giftChargeSourceBag {
@ -143,25 +167,43 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if !active {
continue
}
// 批量礼物逐目标判断主播收益政策;自送礼不排除,背包礼物已在外层过滤。
amount, err := giftDiamondAmount(chargeAmount, policy.HostPointRatioPPM)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
agencyAmount := int64(0)
if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 {
agencyAmount, err = giftDiamondAmount(chargeAmount, policy.AgencyPointRatioPPM)
// 批量目标逐一按其政策和主播区域计算;同一礼物不能拿第一个主播的区域或收益覆盖后续目标。
hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}
giftRatioPercent := "0.00"
giftRatioPPM := int64(0)
giftRatioRegionID := int64(0)
if policy.HostGiftTypeRatioEnabled {
resolvedRatio, err := resolveHostRatio(target.TargetHostRegionID)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
hostGiftTypeRatio = resolvedRatio.ratio
giftRatioPercent = resolvedRatio.ratio.Percent
giftRatioPPM = resolvedRatio.ratio.PPM
giftRatioRegionID = resolvedRatio.effectiveRegionID
}
pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, policy)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
amount := pointAmounts.HostPointAdded
agencyAmount := int64(0)
agencyShareBaseAmount := int64(0)
if target.TargetAgencyOwnerUserID > 0 && policy.AgencyPointRatioPPM > 0 {
agencyAmount = pointAmounts.AgencyPointAdded
agencyShareBaseAmount = pointAmounts.AgencyShareBaseAmount
}
hostPointPolicies[target.CommandID] = targetHostPointPolicy{
policy: policy,
amount: amount,
userID: target.TargetUserID,
agencyAmount: agencyAmount,
agencyOwnerUserID: target.TargetAgencyOwnerUserID,
active: true,
policy: policy,
amount: amount,
userID: target.TargetUserID,
giftRatioPercent: giftRatioPercent,
giftRatioPPM: giftRatioPPM,
giftRatioRegionID: giftRatioRegionID,
agencyAmount: agencyAmount,
agencyShareBaseAmount: agencyShareBaseAmount,
agencyOwnerUserID: target.TargetAgencyOwnerUserID,
active: true,
}
}
}
@ -185,15 +227,16 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
}
}
for _, hostPointPolicy := range hostPointPolicies {
if hostPointPolicy.amount <= 0 {
continue
if hostPointPolicy.amount > 0 {
lockRequests = append(lockRequests, walletAccountLockRequest{
UserID: hostPointPolicy.userID,
AssetType: ledger.AssetPoint,
CreateIfMissing: true,
})
}
lockRequests = append(lockRequests, walletAccountLockRequest{
UserID: hostPointPolicy.userID,
AssetType: ledger.AssetPoint,
CreateIfMissing: true,
})
if hostPointPolicy.agencyAmount > 0 {
// charge_amount 兼容策略可能因两种比例不同而出现 host 向下取整为 0、Agency 仍为正数;
// Agency 账户锁不能被 host amount 的判断包住,否则 Direct 成功而 Batch 返回内部错误。
lockRequests = append(lockRequests, walletAccountLockRequest{
UserID: hostPointPolicy.agencyOwnerUserID,
AssetType: ledger.AssetPoint,
@ -233,28 +276,15 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
events = append(events, resourceEvent)
}
type resolvedHostDiamondRatio struct {
ratio giftDiamondRatioSnapshot
effectiveRegionID int64
}
// 批量送礼中的主播可能属于不同区域,必须逐区域解析周期钻石比例;同一区域只查一次,
// 解析器会在区域配置缺失时回退 region_id=0既避免 N 次重复查询,也不混用其他主播区域。
hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio)
for _, target := range command.Targets {
if !target.TargetIsHost || hostPointPolicies[target.CommandID].active {
continue
}
if _, exists := hostRatiosByRegion[target.TargetHostRegionID]; exists {
continue
}
ratio, effectiveRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, target.TargetHostRegionID, giftConfig.GiftTypeCode)
if err != nil {
if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil {
return ledger.BatchGiftReceipt{}, err
}
hostRatiosByRegion[target.TargetHostRegionID] = resolvedHostDiamondRatio{
ratio: ratio,
effectiveRegionID: effectiveRegionID,
}
}
targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
@ -262,8 +292,12 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
hostPointPolicy := hostPointPolicies[target.CommandID]
hostPeriodDiamondAdded := int64(0)
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
giftDiamondRatioRegionID := int64(0)
giftDiamondRatioPercent := hostPointPolicy.giftRatioPercent
if giftDiamondRatioPercent == "" {
giftDiamondRatioPercent = "0.00"
}
giftDiamondRatioPPM := hostPointPolicy.giftRatioPPM
giftDiamondRatioRegionID := hostPointPolicy.giftRatioRegionID
if target.TargetIsHost && !hostPointPolicy.active {
resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID]
if !exists {
@ -274,6 +308,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
giftDiamondRatioPercent = resolvedRatio.ratio.Percent
giftDiamondRatioPPM = resolvedRatio.ratio.PPM
giftDiamondRatioRegionID = resolvedRatio.effectiveRegionID
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
@ -373,6 +408,7 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioPPM: giftDiamondRatioPPM,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
HostPointAssetType: hostPointPolicy.policy.HostPointAsset,
@ -385,6 +421,8 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
AgencyPointAdded: hostPointPolicy.agencyAmount,
AgencyPointBalanceAfter: agencyPointBalanceAfter,
AgencyPointRatioPPM: hostPointPolicy.policy.AgencyPointRatioPPM,
AgencyPointShareBase: hostPointPolicy.policy.AgencyPointShareBase,
AgencyPointShareBaseAmount: hostPointPolicy.agencyShareBaseAmount,
PointsPerUSD: hostPointPolicy.policy.PointsPerUSD,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
@ -473,6 +511,11 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
if agencyPointState == targetPointState {
// 同一 child transaction 的两个角色共用账户时,回执和主播角色事实都以第二条
// 分录后的最终 POINT 余额为准,不能暴露本批次内部的中间余额。
metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: hostPointPolicy.agencyOwnerUserID,
@ -521,12 +564,28 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if targetGiftIncomeCoinAmount > 0 {
events = append(events, giftIncomeCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
sharedHostAgencyPointAccount := hostPointPolicy.amount > 0 && hostPointPolicy.agencyAmount > 0 && target.TargetUserID == hostPointPolicy.agencyOwnerUserID
sharedPointDelta := int64(0)
if sharedHostAgencyPointAccount {
sharedPointDelta, err = checkedAdd(hostPointPolicy.amount, hostPointPolicy.agencyAmount)
if err != nil {
return ledger.BatchGiftReceipt{}, err
}
}
if hostPointPolicy.amount > 0 {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
if sharedHostAgencyPointAccount {
// 单个 batch target 仍是一笔独立 wallet transaction。owner=self 时只聚合该 target 的
// 通用余额事件,不跨 target 合并;这样每个子 command 的重放与下游游标仍保持独立。
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
} else {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetPoint, hostPointPolicy.amount, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
}
events = append(events, hostPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
if hostPointPolicy.agencyAmount > 0 {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
if !sharedHostAgencyPointAccount {
events = append(events, balanceChangedEvent(transactionID, target.CommandID, hostPointPolicy.agencyOwnerUserID, ledger.AssetPoint, hostPointPolicy.agencyAmount, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizTypeGiftDebit))
}
events = append(events, agencyPointCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
}
if hostPeriodDiamondAdded > 0 {

View File

@ -95,8 +95,14 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
}
// 礼物类型倍率既参与旧周期钻石,也可能参与新 POINT 政策。三项快照在事务内一次解析并落 metadata
// 后续运营调整区域倍率时,历史流水仍能证明 charge_amount 如何得到主播实际收益。
giftDiamondRatioPercent := "0.00"
giftDiamondRatioPPM := int64(0)
giftDiamondRatioRegionID := int64(0)
hostPointAdded := int64(0)
agencyPointAdded := int64(0)
agencyPointShareBaseAmount := int64(0)
hostPointPolicy := walletPolicyInstance{}
hostPointPolicyActive := false
hostPointRegionID := command.TargetHostRegionID
@ -109,26 +115,34 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
if hostPointPolicyActive {
// Huwaa 第一套政策把主播有效付费礼物收益写入 POINT自送礼不被排除背包/机器人礼物不属于有效付费礼物。
hostPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.HostPointRatioPPM)
if err != nil {
return ledger.Receipt{}, err
}
// 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。
giftIncomeCoinAmount = 0
if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 {
// Agency 分成只跟随服务端注入的送礼时归属快照,客户端不能指定;自送礼同样属于有效付费礼物。
agencyPointAdded, err = giftDiamondAmount(chargeAmount, hostPointPolicy.AgencyPointRatioPPM)
// 旧政策直接以 charge_amount 为主播基数Fami 新政策显式开启 gift-type ratio 后,
// 必须先按主播所在区域和礼物类型得到原有钻石收益,再换算 POINT。两种模式都由运行快照决定
// 不能按 app_code 写死,避免未来新 App 复制代码分支。
hostGiftTypeRatio := giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}
if hostPointPolicy.HostGiftTypeRatioEnabled {
hostGiftTypeRatio, giftDiamondRatioRegionID, err = r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode)
if err != nil {
return ledger.Receipt{}, err
}
giftDiamondRatioPercent = hostGiftTypeRatio.Percent
giftDiamondRatioPPM = hostGiftTypeRatio.PPM
}
pointAmounts, err := calculateGiftPointPolicyAmounts(chargeAmount, hostGiftTypeRatio, hostPointPolicy)
if err != nil {
return ledger.Receipt{}, err
}
hostPointAdded = pointAmounts.HostPointAdded
// 命中新政策后不再给主播走旧 COIN 返还,避免同一笔礼物同时产生 COIN 收礼返还和 POINT 收益。
giftIncomeCoinAmount = 0
if command.TargetAgencyOwnerUserID > 0 && hostPointPolicy.AgencyPointRatioPPM > 0 {
// Agency 资格只跟随 gateway 注入并进入 wallet 幂等哈希的归属快照;用户不能自报 owner。
agencyPointAdded = pointAmounts.AgencyPointAdded
agencyPointShareBaseAmount = pointAmounts.AgencyShareBaseAmount
}
}
}
hostPeriodDiamondAdded := int64(0)
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
giftDiamondRatioRegionID := int64(0)
if command.TargetIsHost && !command.RobotGift && !hostPointPolicyActive {
// 主播周期钻石按主播身份所属区域结算,而不是房间可见区域或送礼人区域;
// 区域未配置时由解析器回退 region_id=0保证历史全局配置继续生效。
@ -142,6 +156,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
giftDiamondRatioPercent = ratio.Percent
giftDiamondRatioPPM = ratio.PPM
giftDiamondRatioRegionID = ratioRegionID
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
@ -297,6 +312,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
GiftDiamondRatioPercent: giftDiamondRatioPercent,
GiftDiamondRatioPPM: giftDiamondRatioPPM,
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
HostPeriodCycleKey: hostPeriodCycleKey,
HostPointAssetType: hostPointPolicy.HostPointAsset,
@ -309,6 +325,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
AgencyPointAdded: agencyPointAdded,
AgencyPointBalanceAfter: agencyPointBalanceAfter,
AgencyPointRatioPPM: hostPointPolicy.AgencyPointRatioPPM,
AgencyPointShareBase: hostPointPolicy.AgencyPointShareBase,
AgencyPointShareBaseAmount: agencyPointShareBaseAmount,
PointsPerUSD: hostPointPolicy.PointsPerUSD,
RoomID: command.RoomID,
RoomRegionID: command.RegionID,
@ -431,6 +449,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
metadata.AgencyPointBalanceAfter = agencyPointAccount.AvailableAmount
if agencyPointState == targetPointState {
// owner=self 的两个角色共享一个 POINT 账户;同步回执和 HostPointCredited 必须返回整笔
// 交易完成后的最终余额,而不是只完成主播分录时的中间余额。
metadata.HostPointBalanceAfter = agencyPointAccount.AvailableAmount
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.TargetAgencyOwnerUserID,
@ -483,12 +506,29 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if giftIncomeCoinAmount > 0 {
events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
}
sharedHostAgencyPointAccount := hostPointAdded > 0 && agencyPointAdded > 0 && command.TargetUserID == command.TargetAgencyOwnerUserID
sharedPointDelta := int64(0)
if sharedHostAgencyPointAccount {
sharedPointDelta, err = checkedAdd(hostPointAdded, agencyPointAdded)
if err != nil {
return ledger.Receipt{}, err
}
}
if hostPointAdded > 0 {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType))
if sharedHostAgencyPointAccount {
// 公会长自己收礼时两种角色写同一个 POINT 账户。账本仍保留两条分录和两个角色事实,
// 但通用余额事件的幂等 ID 只含 transaction/user/asset必须合并成最终净变动否则第二条
// WalletBalanceChanged 会撞主键并让整笔扣费事务回滚。
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, sharedPointDelta, 0, metadata.AgencyPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType))
} else {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPoint, hostPointAdded, 0, metadata.HostPointBalanceAfter, targetPointState.account.FrozenAmount, targetPointState.account.Version, metadata, nowMs, bizType))
}
events = append(events, hostPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
}
if agencyPointAdded > 0 {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType))
if !sharedHostAgencyPointAccount {
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPoint, agencyPointAdded, 0, metadata.AgencyPointBalanceAfter, agencyPointState.account.FrozenAmount, agencyPointState.account.Version, metadata, nowMs, bizType))
}
events = append(events, agencyPointCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
}
}

View File

@ -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
}

View File

@ -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% 先向下取整为主播实收 4Agency 再按该 4 的 20% 向下取整为 0不能从原价直接算出 99。
if amounts.HostPointAdded != 4 || amounts.AgencyShareBaseAmount != 4 || amounts.AgencyPointAdded != 0 {
t.Fatalf("unexpected rounded amounts: %+v", amounts)
}
}

View File

@ -42,30 +42,35 @@ type giftMetadata struct {
LastRechargedAtMS int64 `json:"last_recharged_at_ms,omitempty"`
// PaidAtMS 与 wallet_transactions.created_at_ms 同源;写进 metadata 让新交易直接重放,
// 历史 metadata 缺字段时 receipt 查询会从交易主表创建时间补齐。
PaidAtMS int64 `json:"paid_at_ms,omitempty"`
BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"`
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host"`
TargetHostRegionID int64 `json:"target_host_region_id"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
HostPointAssetType string `json:"host_point_asset_type,omitempty"`
HostPointAdded int64 `json:"host_point_added,omitempty"`
HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"`
HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"`
HostPointTemplateCode string `json:"host_point_template_code,omitempty"`
HostPointTemplateVersion string `json:"host_point_template_version,omitempty"`
HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"`
AgencyPointAdded int64 `json:"agency_point_added,omitempty"`
AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"`
AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"`
PaidAtMS int64 `json:"paid_at_ms,omitempty"`
BillingReceipt string `json:"billing_receipt_id"`
SenderUserID int64 `json:"sender_user_id"`
SenderRegionID int64 `json:"sender_region_id"`
TargetUserID int64 `json:"target_user_id"`
TargetIsHost bool `json:"target_is_host"`
TargetHostRegionID int64 `json:"target_host_region_id"`
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
GiftDiamondRatioPPM int64 `json:"gift_diamond_ratio_ppm,omitempty"`
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
HostPeriodCycleKey string `json:"host_period_cycle_key"`
HostPointAssetType string `json:"host_point_asset_type,omitempty"`
HostPointAdded int64 `json:"host_point_added,omitempty"`
HostPointBalanceAfter int64 `json:"host_point_balance_after,omitempty"`
HostPointPolicyInstanceCode string `json:"host_point_policy_instance_code,omitempty"`
HostPointTemplateCode string `json:"host_point_template_code,omitempty"`
HostPointTemplateVersion string `json:"host_point_template_version,omitempty"`
HostPointRatioPPM int64 `json:"host_point_ratio_ppm,omitempty"`
AgencyPointAdded int64 `json:"agency_point_added,omitempty"`
AgencyPointBalanceAfter int64 `json:"agency_point_balance_after,omitempty"`
AgencyPointRatioPPM int64 `json:"agency_point_ratio_ppm,omitempty"`
// AgencyPointShareBase/Amount 是分成公式与基数的不可变交易快照;只存 ratio 无法区分旧的
// charge_amount 口径和 Fami 新的 host_income 额外收益口径。
AgencyPointShareBase string `json:"agency_point_share_base,omitempty"`
AgencyPointShareBaseAmount int64 `json:"agency_point_share_base_amount,omitempty"`
PointsPerUSD int64 `json:"points_per_usd,omitempty"`
RoomID string `json:"room_id"`
RoomRegionID int64 `json:"room_region_id"`

View File

@ -816,27 +816,30 @@ func hostPointCreditedEvent(transactionID string, commandID string, metadata gif
AvailableDelta: metadata.HostPointAdded,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"host_user_id": metadata.TargetUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"region_id": metadata.TargetHostRegionID,
"asset_type": ledger.AssetPoint,
"point_delta": metadata.HostPointAdded,
"point_balance_after": metadata.HostPointBalanceAfter,
"gift_charge_asset_type": metadata.ChargeAssetType,
"gift_charge_amount": metadata.ChargeAmount,
"charge_source": metadata.ChargeSource,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"policy_instance_code": metadata.HostPointPolicyInstanceCode,
"policy_template_code": metadata.HostPointTemplateCode,
"policy_template_version": metadata.HostPointTemplateVersion,
"host_point_ratio_ppm": metadata.HostPointRatioPPM,
"points_per_usd": metadata.PointsPerUSD,
"allow_self_brushing": true,
"created_at_ms": nowMs,
"transaction_id": transactionID,
"command_id": commandID,
"host_user_id": metadata.TargetUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"region_id": metadata.TargetHostRegionID,
"asset_type": ledger.AssetPoint,
"point_delta": metadata.HostPointAdded,
"point_balance_after": metadata.HostPointBalanceAfter,
"gift_charge_asset_type": metadata.ChargeAssetType,
"gift_charge_amount": metadata.ChargeAmount,
"charge_source": metadata.ChargeSource,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"policy_instance_code": metadata.HostPointPolicyInstanceCode,
"policy_template_code": metadata.HostPointTemplateCode,
"policy_template_version": metadata.HostPointTemplateVersion,
"host_point_ratio_ppm": metadata.HostPointRatioPPM,
"gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent,
"gift_diamond_ratio_ppm": metadata.GiftDiamondRatioPPM,
"gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID,
"points_per_usd": metadata.PointsPerUSD,
"allow_self_brushing": true,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
@ -853,24 +856,26 @@ func agencyPointCreditedEvent(transactionID string, commandID string, metadata g
AvailableDelta: metadata.AgencyPointAdded,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"host_user_id": metadata.TargetUserID,
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"asset_type": ledger.AssetPoint,
"point_delta": metadata.AgencyPointAdded,
"point_balance_after": metadata.AgencyPointBalanceAfter,
"gift_charge_asset_type": metadata.ChargeAssetType,
"gift_charge_amount": metadata.ChargeAmount,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"policy_instance_code": metadata.HostPointPolicyInstanceCode,
"policy_template_code": metadata.HostPointTemplateCode,
"policy_template_version": metadata.HostPointTemplateVersion,
"agency_point_ratio_ppm": metadata.AgencyPointRatioPPM,
"created_at_ms": nowMs,
"transaction_id": transactionID,
"command_id": commandID,
"host_user_id": metadata.TargetUserID,
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"asset_type": ledger.AssetPoint,
"point_delta": metadata.AgencyPointAdded,
"point_balance_after": metadata.AgencyPointBalanceAfter,
"gift_charge_asset_type": metadata.ChargeAssetType,
"gift_charge_amount": metadata.ChargeAmount,
"gift_id": metadata.GiftID,
"gift_count": metadata.GiftCount,
"policy_instance_code": metadata.HostPointPolicyInstanceCode,
"policy_template_code": metadata.HostPointTemplateCode,
"policy_template_version": metadata.HostPointTemplateVersion,
"agency_point_ratio_ppm": metadata.AgencyPointRatioPPM,
"agency_share_base": metadata.AgencyPointShareBase,
"agency_share_base_amount": metadata.AgencyPointShareBaseAmount,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"hyapp/pkg/appcode"
@ -11,16 +12,27 @@ import (
"hyapp/services/wallet-service/internal/domain/ledger"
)
const (
// charge_amount 是 ca15f098 上线时的兼容口径Agency 比例直接作用于礼物实扣金额,
// 因而主播比例与 Agency 比例必须共同受 100% 资金池约束。
agencyPointShareBaseChargeAmount = "charge_amount"
// host_income 表示平台在主播本次实际 POINT 收益之外追加 Agency 奖励。
// 该口径先完成礼物类型和主播政策换算,再对最终主播收益取比例,允许出现 100%+20%。
agencyPointShareBaseHostIncome = "host_income"
)
type walletPolicyInstance struct {
InstanceCode string
TemplateCode string
TemplateVersion string
RegionID int64
HostPointAsset string
HostPointRatioPPM int64
AgencyPointRatioPPM int64
PointsPerUSD int64
WithdrawFeeBPS int64
InstanceCode string
TemplateCode string
TemplateVersion string
RegionID int64
HostPointAsset string
HostPointRatioPPM int64
HostGiftTypeRatioEnabled bool
AgencyPointRatioPPM int64
AgencyPointShareBase string
PointsPerUSD int64
WithdrawFeeBPS int64
}
func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMS int64) (walletPolicyInstance, bool, error) {
@ -30,7 +42,11 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
}
row := tx.QueryRowContext(ctx, `
SELECT instance_code, template_code, template_version, region_id, host_point_asset_type,
host_point_ratio_ppm, agency_point_ratio_ppm, points_per_usd, withdraw_fee_bps
host_point_ratio_ppm,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.use_gift_type_ratio')), 'false'),
agency_point_ratio_ppm,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.agency.share_base')), 'charge_amount'),
points_per_usd, withdraw_fee_bps
FROM wallet_policy_instances
WHERE app_code = ?
AND region_id IN (?, 0)
@ -42,7 +58,20 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
appCode, regionID, nowMS, nowMS, regionID,
)
var item walletPolicyInstance
if err := row.Scan(&item.InstanceCode, &item.TemplateCode, &item.TemplateVersion, &item.RegionID, &item.HostPointAsset, &item.HostPointRatioPPM, &item.AgencyPointRatioPPM, &item.PointsPerUSD, &item.WithdrawFeeBPS); err != nil {
var hostGiftTypeRatioRaw string
if err := row.Scan(
&item.InstanceCode,
&item.TemplateCode,
&item.TemplateVersion,
&item.RegionID,
&item.HostPointAsset,
&item.HostPointRatioPPM,
&hostGiftTypeRatioRaw,
&item.AgencyPointRatioPPM,
&item.AgencyPointShareBase,
&item.PointsPerUSD,
&item.WithdrawFeeBPS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return walletPolicyInstance{}, false, nil
}
@ -52,8 +81,33 @@ func (r *Repository) resolveActiveWalletPolicy(ctx context.Context, tx *sql.Tx,
if item.HostPointAsset == "" {
item.HostPointAsset = ledger.AssetPoint
}
if item.HostPointAsset != ledger.AssetPoint || item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 || item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 || item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 || item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 {
parsedGiftTypeRatio, err := strconv.ParseBool(strings.ToLower(strings.TrimSpace(hostGiftTypeRatioRaw)))
if err != nil {
return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy host gift type ratio flag is invalid")
}
item.HostGiftTypeRatioEnabled = parsedGiftTypeRatio
item.AgencyPointShareBase = strings.ToLower(strings.TrimSpace(item.AgencyPointShareBase))
if item.AgencyPointShareBase == "" {
item.AgencyPointShareBase = agencyPointShareBaseChargeAmount
}
if item.HostPointAsset != ledger.AssetPoint ||
item.HostPointRatioPPM <= 0 || item.HostPointRatioPPM > 1_000_000 ||
item.AgencyPointRatioPPM < 0 || item.AgencyPointRatioPPM > 1_000_000 ||
item.PointsPerUSD <= 0 || item.WithdrawFeeBPS < 0 || item.WithdrawFeeBPS > 10_000 {
return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid")
}
switch item.AgencyPointShareBase {
case agencyPointShareBaseChargeAmount:
// 旧实例没有 share_base必须继续执行原先的总资金池守恒约束否则一次重新发布就会
// 把历史 70%+20% 配置悄然解释成可超发政策。
if item.HostPointRatioPPM+item.AgencyPointRatioPPM > 1_000_000 {
return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy instance is invalid")
}
case agencyPointShareBaseHostIncome:
// host_income 是额外平台奖励Agency 百分比相对于主播实际到账值独立校验,不能与
// host_point_ratio_ppm 相加后套用旧的 100% 上限。
default:
return walletPolicyInstance{}, false, xerr.New(xerr.InvalidArgument, "wallet policy agency share base is invalid")
}
return item, true, nil
}

View File

@ -372,7 +372,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示长期有效',
host_point_asset_type VARCHAR(32) NOT NULL DEFAULT 'POINT' COMMENT '主播收益积分资产',
host_point_ratio_ppm BIGINT NOT NULL DEFAULT 700000 COMMENT '有效付费礼物转 POINT 比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner POINT 分成比例ppm',
agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例计算基数由 rule_json agency.share_base 决定ppm',
points_per_usd BIGINT NOT NULL DEFAULT 100000 COMMENT 'POINT/USD 展示换算比例',
withdraw_fee_bps INT NOT NULL DEFAULT 500 COMMENT '提现手续费 bps',
rule_json JSON NOT NULL COMMENT '完整政策快照',
@ -386,7 +386,7 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
if err != nil {
return err
}
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '有效付费礼物给 Agency owner 的 POINT 分成比例ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
if _, err := db.ExecContext(ctx, `ALTER TABLE wallet_policy_instances ADD COLUMN agency_point_ratio_ppm BIGINT NOT NULL DEFAULT 0 COMMENT 'Agency POINT 分成比例,计算基数由 rule_json agency.share_base 决定ppm' AFTER host_point_ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
_, err = db.ExecContext(ctx, `
@ -399,13 +399,26 @@ func ensureWalletPolicyInstanceSchema(ctx context.Context, db *sql.DB) error {
room_id VARCHAR(96) NOT NULL DEFAULT '',
point_delta BIGINT NOT NULL,
ratio_ppm BIGINT NOT NULL,
share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown',
share_base_amount BIGINT NOT NULL DEFAULT 0,
policy_instance_code VARCHAR(96) NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, transaction_id, agency_owner_user_id),
KEY idx_agency_point_share_owner_time (app_code, agency_owner_user_id, created_at_ms),
KEY idx_agency_point_share_host_time (app_code, host_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Agency POINT 礼物分成流水'`)
return err
if err != nil {
return err
}
// 存量库由旧版本创建表时没有公式基数字段;启动补列必须幂等,并在新代码接受送礼前完成,
// 否则账本已加钱而审计流水 INSERT 失败只能依赖事务回滚兜底。
if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_type VARCHAR(32) NOT NULL DEFAULT 'unknown' COMMENT '分成基数unknown/charge_amount/host_income' AFTER ratio_ppm`); err != nil && !isDuplicateColumnError(err) {
return err
}
if _, err := db.ExecContext(ctx, `ALTER TABLE agency_point_share_entries ADD COLUMN share_base_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本次分成计算基数share_base_type=unknown 时数值不可解释' AFTER share_base_type`); err != nil && !isDuplicateColumnError(err) {
return err
}
return nil
}
// ensurePointWithdrawalCoinSellerSchema 创建跨 Fami/Huwaa/Lalu 共用、按 app_code 隔离的提现币商配置。

View File

@ -459,6 +459,47 @@ func (r *Repository) SeedWalletPolicyInstanceWithAgency(appCode string, instance
r.SeedWalletPolicyInstanceWithRates(appCode, instanceCode, regionID, status, hostPointRatioPPM, agencyPointRatioPPM, 100000, 500)
}
// SeedWalletGiftRevenuePolicy 写入送礼收益公式快照专门覆盖“主播先按礼物类型折算、Agency 再按主播收入分成”的政策。
// 旧 SeedWalletPolicyInstance* helper 继续写空 rule_json避免新增测试夹具悄悄改变历史用例的 charge_amount 结算语义。
func (r *Repository) SeedWalletGiftRevenuePolicy(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, hostUseGiftTypeRatio bool, agencyShareBase string) {
r.t.Helper()
appCode = strings.TrimSpace(appCode)
instanceCode = strings.TrimSpace(instanceCode)
status = strings.TrimSpace(status)
agencyShareBase = strings.TrimSpace(agencyShareBase)
nowMs := time.Now().UnixMilli()
ruleJSON, err := json.Marshal(map[string]any{
"host": map[string]any{
"use_gift_type_ratio": hostUseGiftTypeRatio,
},
"agency": map[string]any{
"share_base": agencyShareBase,
},
})
if err != nil {
r.t.Fatalf("marshal wallet gift revenue policy failed: %v", err)
}
_, err = r.schema.DB.ExecContext(context.Background(), `
INSERT INTO wallet_policy_instances (
app_code, instance_code, template_code, template_version, region_id, status,
effective_from_ms, effective_to_ms, host_point_asset_type, host_point_ratio_ppm, agency_point_ratio_ppm,
points_per_usd, withdraw_fee_bps, rule_json, published_by_admin_id,
published_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'v1', ?, ?, 0, 0, 'POINT', ?, ?, 100000, 500, ?, 90001, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
host_point_ratio_ppm = VALUES(host_point_ratio_ppm),
agency_point_ratio_ppm = VALUES(agency_point_ratio_ppm),
rule_json = VALUES(rule_json),
published_at_ms = VALUES(published_at_ms),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, instanceCode, instanceCode+"_template", regionID, status, hostPointRatioPPM, agencyPointRatioPPM, string(ruleJSON), nowMs, nowMs, nowMs)
if err != nil {
r.t.Fatalf("seed wallet gift revenue policy failed: %v", err)
}
}
// SeedWalletPolicyInstanceWithRates 让 POINT 提现和兑币测试都从同一份已发布政策读取分母。
func (r *Repository) SeedWalletPolicyInstanceWithRates(appCode string, instanceCode string, regionID int64, status string, hostPointRatioPPM int64, agencyPointRatioPPM int64, pointsPerUSD int64, feeBPS int64) {
r.t.Helper()
@ -651,18 +692,23 @@ func (r *Repository) SetGiftType(giftID string, giftTypeCode string) {
// SetGiftDiamondRatio configures the gift diamond ratio for a room region and gift type.
func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) {
r.SetGiftDiamondRatioForApp("lalu", regionID, giftTypeCode, ratioPercent)
}
// SetGiftDiamondRatioForApp writes an app-isolated ratio so multi-tenant policy tests can prove the effective-region snapshot.
func (r *Repository) SetGiftDiamondRatioForApp(appCode string, regionID int64, giftTypeCode string, ratioPercent string) {
r.t.Helper()
nowMs := time.Now().UnixMilli()
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO gift_diamond_ratio_configs (
region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, 'active', ?, ?, 0, 0, ?, ?)
) VALUES (?, ?, ?, 'active', ?, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
ratio_percent = VALUES(ratio_percent),
updated_at_ms = VALUES(updated_at_ms)`,
regionID, giftTypeCode, ratioPercent, defaultGiftReturnCoinRatioPercent(giftTypeCode), nowMs, nowMs)
strings.TrimSpace(appCode), regionID, giftTypeCode, ratioPercent, defaultGiftReturnCoinRatioPercent(giftTypeCode), nowMs, nowMs)
if err != nil {
r.t.Fatalf("set gift diamond ratio failed: %v", err)
}