07409eb602
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%
213 lines
8.8 KiB
Go
213 lines
8.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"
|
||
lotterydraw "github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||
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
|
||
LotteryRegistry lottery.Registry
|
||
LotteryDrawService *lotterydraw.Service
|
||
|
||
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)
|
||
|
||
// PR C: PrizeHandler registry + Draw service wiring. Handlers are hooked in
|
||
// order: noop → vpn_duration → commission. Later registrations override.
|
||
registry := lottery.NewRegistry()
|
||
registry.Register(lottery.NewNoopHandler())
|
||
registry.Register(lotteryhandler.NewVPNDurationHandler(lotteryhandler.VPNDurationDeps{
|
||
UserModel: srv.UserModel,
|
||
Ledger: srv.LotteryLedger,
|
||
DB: db,
|
||
ResolveEffectiveUser: lotteryhandler.DefaultResolveEffectiveUser(db),
|
||
}))
|
||
registry.Register(lotteryhandler.NewCommissionHandler(lotteryhandler.CommissionDeps{
|
||
Ledger: srv.LotteryLedger,
|
||
UpdateCommission: srv.UserModel.UpdateCommission,
|
||
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
||
}))
|
||
// Stage 2 人工奖 handler:无外部依赖,直接注册。抽奖服务在 IsAuto()=false
|
||
// 时不会调用 Dispatch,而是由 draw 事务内挂 lottery_claim (pending_claim),
|
||
// 交给运营在后台审核 + 线下打款/发货。
|
||
registry.Register(lotteryhandler.NewCryptoHandler())
|
||
registry.Register(lotteryhandler.NewPhysicalHandler())
|
||
registry.Register(lotteryhandler.NewManualOtherHandler())
|
||
srv.LotteryRegistry = registry
|
||
|
||
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
||
srv.LotteryDrawService = lotterydraw.NewService(lotterydraw.Deps{
|
||
DB: db,
|
||
Enabled: c.Lottery.Enable,
|
||
RateLimiter: lotterydraw.NewRedisRateLimiter(drawLimiter),
|
||
Chance: srv.LotteryChance,
|
||
Evaluator: lottery.NewRuleEvaluator(),
|
||
Picker: lottery.NewWeightedPicker(0),
|
||
Registry: registry,
|
||
// ContextBuilder 留 nil:PR C 阶段无活动配置门槛不评估用户上下文;后续
|
||
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
|
||
})
|
||
if c.S3.Enable {
|
||
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
||
if err != nil {
|
||
panic(err.Error())
|
||
}
|
||
srv.S3Store = s3Store
|
||
}
|
||
return srv
|
||
|
||
}
|