ce3babcc33
Closes HIF-3 Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。 架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。 - 迁移 02158_admin_action_log:后台写操作审计 - xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge) - feature flag config.Lottery.Enable 默认 false,合并后线上零副作用 - draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize - 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010) - 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant - audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要 - QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl 测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100% 100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁) CI 全绿:构建/Vet/测试 + golangci-lint
141 lines
5.2 KiB
Go
141 lines
5.2 KiB
Go
// Package hook contains lottery-side outbound integrations — hooks other flows
|
|
// (order activation, sign-in, etc.) call after they succeed to feed events into
|
|
// the lottery system.
|
|
//
|
|
// All hooks are fire-and-forget by contract: they run in their own goroutine so
|
|
// caller latency and error handling are unaffected. Hook failures are logged
|
|
// and dropped — an invite that fails to earn a lottery chance never blocks the
|
|
// order it was piggy-backing on.
|
|
package hook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/perfect-panel/server/internal/model/lottery"
|
|
"github.com/perfect-panel/server/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// InviteHook is fired by order/renewal activation when an invited user
|
|
// completes a payment. It grants lottery chances to the referer across every
|
|
// currently running activity that declares an "invite_success" chance source.
|
|
type InviteHook interface {
|
|
// OnConversion queues a background grant for referer. Returns immediately.
|
|
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
|
|
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
|
|
}
|
|
|
|
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
|
|
// value before the lottery system is wired in. Its OnConversion returns
|
|
// immediately without side effects — no goroutine, no log spam.
|
|
func NoopInviteHook() InviteHook { return noopInviteHook{} }
|
|
|
|
type noopInviteHook struct{}
|
|
|
|
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
|
|
|
|
// defaultInviteHook is the production implementation. It queries running
|
|
// activities on every call rather than caching them — the query is cheap
|
|
// (small table, indexed by status+time), and skipping the cache avoids stale
|
|
// reads when an activity is paused or its chance_sources are re-configured.
|
|
type defaultInviteHook struct {
|
|
db *gorm.DB
|
|
chance lottery.ChanceService
|
|
}
|
|
|
|
// NewInviteHook builds the production invite hook.
|
|
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
|
|
if db == nil || chance == nil {
|
|
return NoopInviteHook()
|
|
}
|
|
return &defaultInviteHook{db: db, chance: chance}
|
|
}
|
|
|
|
// OnConversion spawns a fire-and-forget goroutine that walks all running
|
|
// activities and calls ChanceService.Grant for each one that declares an
|
|
// invite_success source.
|
|
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
|
|
if refererUserID <= 0 || orderNo == "" {
|
|
return
|
|
}
|
|
go h.run(refererUserID, orderNo)
|
|
}
|
|
|
|
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
|
|
// Fresh context so the caller cancelling their goroutine does not abort us.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
activities, err := h.loadRunningActivities(ctx)
|
|
if err != nil {
|
|
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("referer_user_id", refererUserID),
|
|
logger.Field("order_no", orderNo),
|
|
)
|
|
return
|
|
}
|
|
|
|
for i := range activities {
|
|
activity := &activities[i]
|
|
grants := parseInviteGrantsFromSources(activity.ChanceSources)
|
|
for _, amount := range grants {
|
|
if amount <= 0 {
|
|
continue
|
|
}
|
|
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
|
|
// Prefix orderNo with "order:" so audit trails can tell business-order
|
|
// derived refs apart from other source families (manual_grant uses
|
|
// "manual:*", daily_signin uses "signin:*"). DB uniqueness is already
|
|
// bucketed by source, but the prefix makes log/analytics readable.
|
|
ref := "order:" + orderNo
|
|
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, amount); err != nil {
|
|
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("referer_user_id", refererUserID),
|
|
logger.Field("activity_id", activity.Id),
|
|
logger.Field("order_no", orderNo),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
|
|
now := time.Now()
|
|
var activities []lottery.Activity
|
|
if err := h.db.WithContext(ctx).
|
|
Model(&lottery.Activity{}).
|
|
Where("status = ?", lottery.ActivityStatusRunning).
|
|
Where("start_at <= ? AND end_at >= ?", now, now).
|
|
Find(&activities).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return activities, nil
|
|
}
|
|
|
|
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
|
|
// activity and returns the per-conversion grant amount for each invite_success
|
|
// source (an activity may declare multiple, e.g. with different params by
|
|
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
|
|
func parseInviteGrantsFromSources(raw string) []int {
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
var sources []lottery.ChanceSource
|
|
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
|
// Malformed configs skip silently — the activity is misconfigured, not
|
|
// a hook fault. Admin CRUD (PR C) will surface it.
|
|
return nil
|
|
}
|
|
out := make([]int, 0, len(sources))
|
|
for _, s := range sources {
|
|
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
|
|
out = append(out, s.Amount)
|
|
}
|
|
}
|
|
return out
|
|
}
|