新功能(#3): 抽奖 Stage 1 handler 真实业务对接 + 邀请钩子
Closes HIF-3 (阶段 PR B) PR B:handler 真实业务对接 + 邀请钩子(迭代含 R1 修复) - 迁移 02157_lottery_grant_ledger:external_ref UNIQUE 作为发奖幂等键 - log.CommissionTypeLottery=339(架构师批准的新常量) - DispatchRequest.IdempotencyKey(架构师 review 建议第 2 条) - GrantLedger + LedgerService.Reserve:INSERT ON CONFLICT DO NOTHING 幂等 upsert - VPNDurationHandler:ResolveEffectiveUser 归位家庭 owner + UpdateSubscribe,ExpireTime 三分支对齐 grantGiftDays - CommissionHandler:UpdateCommission + WriteCommissionLog(339),发给中奖者本人,不做家庭组归位 - Handler 从 model 层迁到 logic 层(避免 model → logic 反向依赖);noop 保留在 model 层 - InviteHook:fire-and-forget 独立 goroutine + 10s timeout,扫 running 活动的 invite_success 源 - ServiceContext 新增 LotteryChance / LotteryLedger / LotteryInviteHook - activateOrderLogic.handleCommission 两条分支通过 invokeInviteHookIfEligible 助手触发,助手内统一 gate IsNew(架构师 R1 打回后的修复) 架构师 R1 打回:branch B 未按"首次付款激活"gate,续费也会给 referer 发抽奖机会 → 已通过助手函数集中收拢,避免 branch A/B 判断漂移。 测试覆盖率:model 76.5% / handler 73.2% / hook 91.7%;补 4 个回归测试覆盖 IsNew 门槛(含关键的 DoesNotFireOnRenewal)。 线上仍零可见变更:无对外路由,钩子仅在活动 status=running 时生效,Stage 1 全流程无活动记录时 loadRunningActivities 返回空。
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommissionHandler 发放"抽奖佣金"。写 user.commission 增量 + system_logs
|
||||
// (Type=Commission, CommissionType=339 Lottery) —— 用新增的 CommissionTypeLottery
|
||||
// 常量与 Purchase/Renewal 区分,账目侧对账更清晰。
|
||||
//
|
||||
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||
// Reserve 命中即幂等,未命中才走真实发放。commission 直接发给中奖者本人,不做
|
||||
// 家庭组归位(family owner 不代收成员的抽奖佣金)。
|
||||
type CommissionHandler struct {
|
||||
deps CommissionDeps
|
||||
}
|
||||
|
||||
// CommissionDeps 是 CommissionHandler 需要的最小依赖集。抽出到接口方便测试。
|
||||
type CommissionDeps struct {
|
||||
Ledger lottery.LedgerService
|
||||
// UpdateCommission 对齐 UserModel.UpdateCommission 签名。
|
||||
UpdateCommission func(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
|
||||
// WriteCommissionLog 对齐 common.WriteCommissionLog 签名。
|
||||
WriteCommissionLog func(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error
|
||||
}
|
||||
|
||||
// NewCommissionHandler 构造真实的 commission handler。
|
||||
func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
|
||||
return &CommissionHandler{deps: deps}
|
||||
}
|
||||
|
||||
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
||||
func (*CommissionHandler) IsAuto() bool { return true }
|
||||
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
||||
|
||||
type commissionConfig struct {
|
||||
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
||||
AmountCents int64 `json:"amount_cents"`
|
||||
}
|
||||
|
||||
type commissionPayload struct {
|
||||
Amount int64 `json:"amount"`
|
||||
LogType uint16 `json:"log_type"`
|
||||
Message string `json:"message"`
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
// Dispatch 在 caller 的事务内为中奖人发放佣金。
|
||||
func (h *CommissionHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
if tx == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires a transaction")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires DispatchRequest.IdempotencyKey")
|
||||
}
|
||||
if h.deps.UpdateCommission == nil || h.deps.WriteCommissionLog == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler deps not fully wired")
|
||||
}
|
||||
|
||||
var cfg commissionConfig
|
||||
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("decode commission config: %w", err)
|
||||
}
|
||||
if cfg.AmountCents <= 0 {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("commission config amount_cents must be > 0, got %d", cfg.AmountCents)
|
||||
}
|
||||
|
||||
// 佣金"发给中奖者本人"(不走家庭组归位)。
|
||||
targetUserID := req.UserId
|
||||
|
||||
entry := lottery.GrantLedger{
|
||||
ExternalRef: req.IdempotencyKey,
|
||||
HandlerType: lottery.PrizeTypeCommission,
|
||||
UserId: targetUserID,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: req.DrawId,
|
||||
Amount: cfg.AmountCents,
|
||||
}
|
||||
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||
}
|
||||
if alreadyExisted {
|
||||
var payload commissionPayload
|
||||
if row.Payload != "" {
|
||||
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||
}
|
||||
if payload.Message == "" {
|
||||
payload.Message = "佣金已到账"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。UpdateCommission 用 gorm.Expr 原子累加,避免丢更新。
|
||||
if err := h.deps.UpdateCommission(ctx, targetUserID, cfg.AmountCents, tx); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update commission for user %d: %w", targetUserID, err)
|
||||
}
|
||||
// 传 external_ref 到 WriteCommissionLog 的 orderNo 位("lottery:*"),与业务 order 命名域天然区分。
|
||||
if err := h.deps.WriteCommissionLog(tx, targetUserID, logmodel.CommissionTypeLottery, cfg.AmountCents, req.IdempotencyKey); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("write commission log: %w", err)
|
||||
}
|
||||
|
||||
payload := commissionPayload{
|
||||
Amount: cfg.AmountCents,
|
||||
LogType: logmodel.CommissionTypeLottery,
|
||||
OrderNo: req.IdempotencyKey,
|
||||
Message: fmt.Sprintf("佣金已到账 %d", cfg.AmountCents),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("marshal ledger payload: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&lottery.GrantLedger{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update ledger payload: %w", err)
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user