Files
hi-server/internal/svc/serviceContext.go
T
shanshanzhong147 a46fb83054 新功能(#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 返回空。
2026-07-08 21:19:28 -07:00

173 lines
6.8 KiB
Go

package svc
import (
"context"
"time"
"github.com/perfect-panel/server/internal/model/client"
"github.com/perfect-panel/server/internal/model/node"
"github.com/perfect-panel/server/internal/model/redemption"
"github.com/perfect-panel/server/pkg/device"
"github.com/perfect-panel/server/pkg/storage"
"github.com/perfect-panel/server/internal/config"
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
"github.com/perfect-panel/server/internal/model/ads"
"github.com/perfect-panel/server/internal/model/announcement"
"github.com/perfect-panel/server/internal/model/auth"
"github.com/perfect-panel/server/internal/model/coupon"
"github.com/perfect-panel/server/internal/model/document"
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/log"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/model/promo"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/system"
"github.com/perfect-panel/server/internal/model/ticket"
"github.com/perfect-panel/server/internal/model/traffic"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/nodeMultiplier"
"github.com/perfect-panel/server/pkg/orm"
"github.com/perfect-panel/server/pkg/signature"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type ServiceContext struct {
DB *gorm.DB
Redis *redis.Client
Config config.Config
Queue *asynq.Client
ExchangeRate float64
GeoIP *IPLocation
SignatureValidator *signature.Validator
S3Store *storage.S3Store
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
AdsModel ads.Model
LogModel log.Model
LogMessageModel logmessage.Model
NodeModel node.Model
UserModel user.Model
OrderModel order.Model
ClientModel client.Model
TicketModel ticket.Model
//ServerModel server.Model
SystemModel system.Model
CouponModel coupon.Model
RedemptionCodeModel redemption.RedemptionCodeModel
RedemptionRecordModel redemption.RedemptionRecordModel
PaymentModel payment.Model
PromoModel promo.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
TrafficLogModel traffic.Model
AnnouncementModel announcement.Model
IAPAppleTransactionModel iapapple.Model
// Lottery (Stage 1)
LotteryChance lottery.ChanceService
LotteryLedger lottery.LedgerService
LotteryInviteHook lotteryhook.InviteHook
Restart func() error
TelegramBot *tgbotapi.BotAPI
NodeMultiplierManager *nodeMultiplier.Manager
AuthLimiter *limit.PeriodLimit
DeviceManager *device.DeviceManager
}
func NewServiceContext(c config.Config) *ServiceContext {
// gorm initialize
db, err := orm.ConnectMysql(orm.Mysql{
Config: c.MySQL,
})
if err != nil {
panic(err.Error())
}
// IP location initialize
geoIP, err := NewIPLocation("./cache/GeoLite2-City.mmdb")
if err != nil {
panic(err.Error())
}
rds := redis.NewClient(&redis.Options{
Addr: c.Redis.Host,
Password: c.Redis.Pass,
DB: c.Redis.DB,
PoolSize: c.Redis.PoolSize, // 连接池大小:根据应用并发量调整,建议 100-500
MinIdleConns: c.Redis.MinIdleConns, // 最小空闲连接:保持一定数量的空闲连接,减少建立连接的开销
MaxRetries: c.Redis.MaxRetries, // 最大重试次数:网络抖动时自动重试
PoolTimeout: time.Second * time.Duration(c.Redis.PoolTimeout), // 从连接池获取连接的超时时间
ConnMaxIdleTime: time.Second * time.Duration(c.Redis.IdleTimeout), // 空闲连接的超时时间,自动回收长时间空闲的连接
ConnMaxLifetime: time.Second * time.Duration(c.Redis.MaxConnAge), // 连接的最大生命周期,定期重建连接避免长时间使用的问题
DialTimeout: time.Second * time.Duration(c.Redis.DialTimeout), // 建立新连接的超时时间
ReadTimeout: time.Second * time.Duration(c.Redis.ReadTimeout), // 读操作超时时间
WriteTimeout: time.Second * time.Duration(c.Redis.WriteTimeout), // 写操作超时时间
})
err = rds.Ping(context.Background()).Err()
if err != nil {
panic(err.Error())
}
authLimiter := limit.NewPeriodLimit(86400, 15, rds, config.SendCountLimitKeyPrefix, limit.Align())
nonceStore := signature.NewRedisNonceStore(rds)
srv := &ServiceContext{
DB: db,
Redis: rds,
Config: c,
Queue: NewAsynqClient(c),
ExchangeRate: 0,
GeoIP: geoIP,
SignatureValidator: signature.NewValidator(c.AppSignature, nonceStore),
//NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),
LogModel: log.NewModel(db),
LogMessageModel: logmessage.NewModel(db),
NodeModel: node.NewModel(db, rds),
AuthModel: auth.NewModel(db, rds),
UserModel: user.NewModel(db, rds),
OrderModel: order.NewModel(db, rds),
ClientModel: client.NewSubscribeApplicationModel(db),
TicketModel: ticket.NewModel(db, rds),
//ServerModel: server.NewModel(db, rds),
SystemModel: system.NewModel(db, rds),
CouponModel: coupon.NewModel(db, rds),
RedemptionCodeModel: redemption.NewRedemptionCodeModel(db, rds),
RedemptionRecordModel: redemption.NewRedemptionRecordModel(db, rds),
PaymentModel: payment.NewModel(db, rds),
PromoModel: promo.NewModel(db, rds),
DocumentModel: document.NewModel(db, rds),
SubscribeModel: subscribe.NewModel(db, rds),
TrafficLogModel: traffic.NewModel(db),
AnnouncementModel: announcement.NewModel(db, rds),
}
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
srv.DeviceManager = NewDeviceManager(srv)
// Lottery Stage 1: wire the always-safe pieces (ChanceService, LedgerService,
// InviteHook). PrizeHandler registry is created in the draw service (PR C).
srv.LotteryChance = lottery.NewChanceService(db)
srv.LotteryLedger = lottery.NewLedgerService()
srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance)
if c.S3.Enable {
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
if err != nil {
panic(err.Error())
}
srv.S3Store = s3Store
}
return srv
}