Files
hi-server/internal/svc/serviceContext.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

213 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 留 nilPR 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
}