Files
hi-server/internal/logic/lottery/handler/commission.go
T
shanshanzhong147 07409eb602 新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
2026-07-10 04:01:34 -07:00

129 lines
5.0 KiB
Go

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 }
func (*CommissionHandler) ClaimSchema() json.RawMessage { 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
}