From 9933d34bdd3f85b5aa5b9db160af6ddb82c74f8b Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Wed, 8 Jul 2026 20:05:05 -0700 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD(#3):=20=E6=8A=BD?= =?UTF-8?q?=E5=A5=96=E6=B4=BB=E5=8A=A8=20Stage=201=20=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=EF=BC=88DB=20+=20=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=20+=20Ha?= =?UTF-8?q?ndler=20=E6=8A=BD=E8=B1=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes HIF-3 Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。 - 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理) - CI 全绿(构建/Vet/测试 + golangci-lint) - 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更 架构师 review 通过,4 项决策已在 issue 上给出: 1. 邀请转化语义 = 首次付款激活 2. 佣金日志类型 = 新增 CommissionTypeLottery=339 3. 家庭组归属 = 穿透到 owner 4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层) 后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。 --- .../database/02156_lottery_stage1.down.sql | 11 + .../database/02156_lottery_stage1.up.sql | 125 +++++++++ internal/model/lottery/chance_service.go | 116 ++++++++ internal/model/lottery/chance_service_test.go | 197 ++++++++++++++ internal/model/lottery/model.go | 184 +++++++++++++ internal/model/lottery/registry.go | 94 +++++++ internal/model/lottery/registry_test.go | 59 ++++ internal/model/lottery/rule_evaluator.go | 252 ++++++++++++++++++ internal/model/lottery/rule_evaluator_test.go | 193 ++++++++++++++ internal/model/lottery/service.go | 122 +++++++++ internal/model/lottery/weighted_picker.go | 66 +++++ .../model/lottery/weighted_picker_test.go | 76 ++++++ 12 files changed, 1495 insertions(+) create mode 100644 initialize/migrate/database/02156_lottery_stage1.down.sql create mode 100644 initialize/migrate/database/02156_lottery_stage1.up.sql create mode 100644 internal/model/lottery/chance_service.go create mode 100644 internal/model/lottery/chance_service_test.go create mode 100644 internal/model/lottery/model.go create mode 100644 internal/model/lottery/registry.go create mode 100644 internal/model/lottery/registry_test.go create mode 100644 internal/model/lottery/rule_evaluator.go create mode 100644 internal/model/lottery/rule_evaluator_test.go create mode 100644 internal/model/lottery/service.go create mode 100644 internal/model/lottery/weighted_picker.go create mode 100644 internal/model/lottery/weighted_picker_test.go diff --git a/initialize/migrate/database/02156_lottery_stage1.down.sql b/initialize/migrate/database/02156_lottery_stage1.down.sql new file mode 100644 index 0000000..aa9b2da --- /dev/null +++ b/initialize/migrate/database/02156_lottery_stage1.down.sql @@ -0,0 +1,11 @@ +-- 02156 抽奖活动 Stage 1 回滚 +-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前 +-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。 + +DROP TABLE IF EXISTS `lottery_eligibility_snapshot`; +DROP TABLE IF EXISTS `lottery_prize_snapshot`; +DROP TABLE IF EXISTS `lottery_draw`; +DROP TABLE IF EXISTS `lottery_chance_grant`; +DROP TABLE IF EXISTS `lottery_chance_balance`; +DROP TABLE IF EXISTS `lottery_prize`; +DROP TABLE IF EXISTS `lottery_activity`; diff --git a/initialize/migrate/database/02156_lottery_stage1.up.sql b/initialize/migrate/database/02156_lottery_stage1.up.sql new file mode 100644 index 0000000..00b3eea --- /dev/null +++ b/initialize/migrate/database/02156_lottery_stage1.up.sql @@ -0,0 +1,125 @@ +-- 02156 抽奖活动 Stage 1(后端核心闭环) +-- +-- 新建 7 张表 + 全部索引 + 幂等约束。 +-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检 +-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。 +-- +-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删): +-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品 +-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw +-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行 +-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会) +-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1 +-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1 + +CREATE TABLE IF NOT EXISTS `lottery_activity` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题', + `description` TEXT COMMENT '活动描述(Markdown)', + `start_at` DATETIME NOT NULL COMMENT '开始时间', + `end_at` DATETIME NOT NULL COMMENT '结束时间', + `status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended', + `grid_size` TINYINT NOT NULL DEFAULT 9 COMMENT '前端九宫格数量(3/6/8/9/12)', + `eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)', + `chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant)', + `unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_status_time` (`status`, `start_at`, `end_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动'; + +CREATE TABLE IF NOT EXISTS `lottery_prize` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID', + `slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based)', + `type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none', + `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称', + `icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL', + `config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3})', + `weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)', + `total_stock` BIGINT COMMENT '总库存(NULL 表示无限)', + `remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL)', + `is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)', + `version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`), + KEY `idx_activity_fallback` (`activity_id`, `is_fallback`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义'; + +CREATE TABLE IF NOT EXISTS `lottery_chance_balance` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID', + `remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1)', + `total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)', + `total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额'; + +CREATE TABLE IF NOT EXISTS `lottery_chance_grant` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID', + `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID', + `source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant', + `source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}")', + `amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数', + `expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)', + `granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`), + KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref)'; + +CREATE TABLE IF NOT EXISTS `lottery_draw` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID', + `client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID)', + `prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL)', + `is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)', + `dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed', + `dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)', + `dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)', + `drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`), + KEY `idx_user_time` (`user_id`, `drawn_at`), + KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`), + KEY `idx_activity_prize` (`activity_id`, `prize_id`), + KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录'; + +CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID', + `prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id)', + `slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)', + `type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)', + `name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)', + `config` JSON NOT NULL COMMENT '类型专属配置(快照)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_draw_id` (`draw_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)'; + +CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID', + `activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID', + `passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)', + `unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required)', + `evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_draw_id` (`draw_id`), + KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)'; diff --git a/internal/model/lottery/chance_service.go b/internal/model/lottery/chance_service.go new file mode 100644 index 0000000..3cb11d7 --- /dev/null +++ b/internal/model/lottery/chance_service.go @@ -0,0 +1,116 @@ +package lottery + +import ( + "context" + "errors" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// chanceService 是 ChanceService 的默认实现。 +type chanceService struct { + db *gorm.DB +} + +// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。 +func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} } + +// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。 +// 幂等策略: +// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突 +// 2. 冲突视为"已发过",直接返回 nil 不重复发放 +// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining +// +// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账, +// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。 +func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error { + if amount <= 0 { + return nil + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + grant := ChanceGrant{ + UserId: userId, + ActivityId: activityId, + Source: source, + SourceRef: sourceRef, + Amount: amount, + } + // OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。 + res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + // 幂等命中:已经发过,balance 不动。 + return nil + } + + // 未冲突 → 累加余额(upsert balance 行)。 + balance := ChanceBalance{ + UserId: userId, + ActivityId: activityId, + Remaining: int64(amount), + TotalEarned: int64(amount), + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount), + "total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount), + }), + }).Create(&balance).Error + }) +} + +// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。 +// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。 +func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) { + if tx == nil { + return 0, errors.New("Consume requires a transaction handle") + } + var balance ChanceBalance + err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_id = ? AND activity_id = ?", userId, activityId). + First(&balance).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, ErrNoChances + } + return 0, err + } + if balance.Remaining <= 0 { + return 0, ErrNoChances + } + // 累加 spent,扣减 remaining,一条 SQL 完成。 + updateErr := tx.WithContext(ctx). + Model(&ChanceBalance{}). + Where("id = ? AND remaining > 0", balance.Id). + Updates(map[string]interface{}{ + "remaining": gorm.Expr("`remaining` - 1"), + "total_spent": gorm.Expr("`total_spent` + 1"), + }).Error + if updateErr != nil { + return 0, updateErr + } + return balance.Remaining - 1, nil +} + +// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。 +func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) { + var balance ChanceBalance + err := s.db.WithContext(ctx). + Where("user_id = ? AND activity_id = ?", userId, activityId). + First(&balance).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + if balance.Remaining < 0 { + return 0, nil + } + return balance.Remaining, nil +} diff --git a/internal/model/lottery/chance_service_test.go b/internal/model/lottery/chance_service_test.go new file mode 100644 index 0000000..c7238fe --- /dev/null +++ b/internal/model/lottery/chance_service_test.go @@ -0,0 +1,197 @@ +package lottery + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if strings.Contains(actual, expected) { + return nil + } + return fmt.Errorf("actual sql %q does not contain %q", actual, expected) + }))) + if err != nil { + t.Fatalf("create sqlmock: %v", err) + } + db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("open gorm db: %v", err) + } + return db, mock, func() { _ = sqlDB.Close() } +} + +func TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + svc := NewChanceService(db) + if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil { + t.Fatalf("Grant(amount=0) unexpected err: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("no queries expected, got: %v", err) + } +} + +func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectBegin() + // INSERT lottery_chance_grant,未冲突返回 1 行 + mock.ExpectExec("INSERT INTO `lottery_chance_grant`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + // UPSERT lottery_chance_balance + mock.ExpectExec("INSERT INTO `lottery_chance_balance`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + svc := NewChanceService(db) + if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil { + t.Fatalf("Grant: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectBegin() + // INSERT lottery_chance_grant,UNIQUE 冲突 → 0 行影响 + mock.ExpectExec("INSERT INTO `lottery_chance_grant`"). + WillReturnResult(sqlmock.NewResult(0, 0)) + // balance 不应被触发 + mock.ExpectCommit() + + svc := NewChanceService(db) + if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil { + t.Fatalf("Grant: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestChanceService_Consume_LocksAndDecrements(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `lottery_chance_balance`"). + WithArgs(int64(42), int64(100), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}). + AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1))) + mock.ExpectExec("UPDATE `lottery_chance_balance`"). + WithArgs(sqlmock.AnyArg(), int64(9)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + svc := NewChanceService(db) + var remaining int64 + err := db.Transaction(func(tx *gorm.DB) error { + var e error + remaining, e = svc.Consume(context.Background(), tx, 42, 100) + return e + }) + if err != nil { + t.Fatalf("Consume: %v", err) + } + if remaining != 1 { + t.Fatalf("expected remaining=1, got %d", remaining) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `lottery_chance_balance`"). + WithArgs(int64(42), int64(100), 1). + WillReturnError(gorm.ErrRecordNotFound) + mock.ExpectRollback() + + svc := NewChanceService(db) + err := db.Transaction(func(tx *gorm.DB) error { + _, e := svc.Consume(context.Background(), tx, 42, 100) + return e + }) + if !errors.Is(err, ErrNoChances) { + t.Fatalf("expected ErrNoChances, got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectBegin() + mock.ExpectQuery("FROM `lottery_chance_balance`"). + WithArgs(int64(42), int64(100), 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}). + AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3))) + mock.ExpectRollback() + + svc := NewChanceService(db) + err := db.Transaction(func(tx *gorm.DB) error { + _, e := svc.Consume(context.Background(), tx, 42, 100) + return e + }) + if !errors.Is(err, ErrNoChances) { + t.Fatalf("expected ErrNoChances when remaining=0, got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +func TestChanceService_Consume_RequiresTx(t *testing.T) { + db, _, cleanup := newLotteryTestDB(t) + defer cleanup() + + svc := NewChanceService(db) + if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil { + t.Fatalf("expected error when tx is nil") + } +} + +func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + mock.ExpectQuery("FROM `lottery_chance_balance`"). + WithArgs(int64(42), int64(100), 1). + WillReturnError(gorm.ErrRecordNotFound) + + svc := NewChanceService(db) + got, err := svc.Query(context.Background(), 42, 100) + if err != nil { + t.Fatalf("Query: %v", err) + } + if got != 0 { + t.Fatalf("expected 0 when no row, got %d", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} diff --git a/internal/model/lottery/model.go b/internal/model/lottery/model.go new file mode 100644 index 0000000..7793733 --- /dev/null +++ b/internal/model/lottery/model.go @@ -0,0 +1,184 @@ +package lottery + +import ( + "database/sql" + "encoding/json" + "time" + + "gorm.io/gorm" +) + +// ---- 状态与类型常量 --------------------------------------------------------- + +const ( + ActivityStatusDraft = "draft" + ActivityStatusRunning = "running" + ActivityStatusPaused = "paused" + ActivityStatusEnded = "ended" + + UnmetActionBlock = "block" + UnmetActionShowReason = "show_reason" + + // PrizeType* 是发奖 handler 注册表的键。Stage 1 只实现前三种。 + PrizeTypeVPNDuration = "vpn_duration" + PrizeTypeCommission = "commission" + PrizeTypeNone = "none" + // Stage 2/3 预留。 + PrizeTypeBalance = "balance" + PrizeTypeGiftAmount = "gift_amount" + PrizeTypeCoupon = "coupon" + PrizeTypePoints = "points" + PrizeTypeEncrypted = "encrypted" + PrizeTypePhysical = "physical" + PrizeTypeManualOther = "manual_other" + + // ChanceSource* 是次数入账触发源。 + ChanceSourceDailySignin = "daily_signin" + ChanceSourceNewSubscription = "new_subscription" + ChanceSourceInviteSuccess = "invite_success" + ChanceSourceManualGrant = "manual_grant" + + // DispatchState* 是 lottery_draw.dispatch_state 的取值。 + DispatchStateNone = "none" + DispatchStateAutoClaimed = "auto_claimed" + DispatchStatePendingClaim = "pending_claim" + DispatchStatePaid = "paid" + DispatchStateExpired = "expired" + DispatchStateFailed = "failed" +) + +// ---- 实体 ----------------------------------------------------------------- + +type Activity struct { + Id int64 `gorm:"primaryKey"` + Title string `gorm:"type:varchar(128);not null;default:'';comment:活动标题"` + Description string `gorm:"type:text;comment:活动描述"` + StartAt time.Time `gorm:"not null;comment:开始时间"` + EndAt time.Time `gorm:"not null;comment:结束时间"` + Status string `gorm:"type:varchar(16);not null;default:'draft';comment:状态"` + GridSize int `gorm:"type:tinyint;not null;default:9;comment:九宫格数量"` + Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"` + ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"` + UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"` +} + +func (Activity) TableName() string { return "lottery_activity" } + +type Prize struct { + Id int64 `gorm:"primaryKey"` + ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:所属活动"` + Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"` + Type string `gorm:"type:varchar(32);not null;comment:奖品类型"` + Name string `gorm:"type:varchar(128);not null;default:'';comment:名称"` + IconURL string `gorm:"type:varchar(512);not null;default:'';comment:图标 URL"` + Config string `gorm:"type:json;not null;comment:类型专属配置"` + Weight int `gorm:"type:int;not null;default:0;comment:加权随机权重"` + TotalStock sql.NullInt64 `gorm:"type:bigint;comment:总库存(NULL=无限)"` + RemainingStock sql.NullInt64 `gorm:"type:bigint;comment:剩余库存(NULL=无限)"` + IsFallback bool `gorm:"type:tinyint(1);not null;default:0;comment:是否为保底奖"` + Version int64 `gorm:"type:bigint;not null;default:0;comment:乐观锁"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (Prize) TableName() string { return "lottery_prize" } + +type ChanceBalance struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"` + ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"` + Remaining int64 `gorm:"type:bigint;not null;default:0;comment:剩余次数"` + TotalEarned int64 `gorm:"type:bigint;not null;default:0;comment:累计入账"` + TotalSpent int64 `gorm:"type:bigint;not null;default:0;comment:累计消耗"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (ChanceBalance) TableName() string { return "lottery_chance_balance" } + +type ChanceGrant struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"` + ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"` + Source string `gorm:"type:varchar(32);not null;comment:触发源"` + SourceRef string `gorm:"type:varchar(128);not null;comment:外部业务幂等键"` + Amount int `gorm:"type:int;not null;default:0;comment:本次发放次数"` + ExpiresAt *time.Time `gorm:"default:null;comment:到期时间"` + GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:入账时间"` +} + +func (ChanceGrant) TableName() string { return "lottery_chance_grant" } + +type Draw struct { + Id int64 `gorm:"primaryKey"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"` + ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"` + ClientNonce string `gorm:"type:varchar(64);not null;comment:前端幂等键"` + PrizeId sql.NullInt64 `gorm:"type:bigint unsigned;comment:中奖奖品 ID(谢谢参与=NULL)"` + IsWin bool `gorm:"type:tinyint(1);not null;default:0;comment:是否中奖"` + DispatchState string `gorm:"type:varchar(16);not null;default:'none';comment:发放状态"` + DispatchError string `gorm:"type:text;comment:发放失败原因"` + DispatchedAt *time.Time `gorm:"default:null;comment:发放完成时间"` + DrawnAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:抽奖时间"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` + UpdatedAt time.Time `gorm:"comment:Update Time"` +} + +func (Draw) TableName() string { return "lottery_draw" } + +type PrizeSnapshot struct { + Id int64 `gorm:"primaryKey"` + DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"` + PrizeId int64 `gorm:"type:bigint unsigned;not null;comment:奖品 ID"` + Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"` + Type string `gorm:"type:varchar(32);not null;comment:奖品类型"` + Name string `gorm:"type:varchar(128);not null;comment:奖品名称"` + Config string `gorm:"type:json;not null;comment:类型专属配置"` + CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` +} + +func (PrizeSnapshot) TableName() string { return "lottery_prize_snapshot" } + +type EligibilitySnapshot struct { + Id int64 `gorm:"primaryKey"` + DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"` + UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"` + ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"` + Passed bool `gorm:"type:tinyint(1);not null;default:0;comment:是否通过门槛"` + UnmetReasons string `gorm:"type:json;comment:未通过项"` + EvaluatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:评估时间"` +} + +func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" } + +// ---- JSON 结构体辅助 ------------------------------------------------------- + +// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。 +// 每个节点要么是"叶子"(Type 非空),要么是"聚合"(Op 为 AND/OR + Children)。 +type EligibilityRule struct { + Op string `json:"op,omitempty"` + Children []*EligibilityRule `json:"children,omitempty"` + + Type string `json:"type,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +// ChanceSource 描述次数来源的一条配置,持久化在 lottery_activity.chance_sources。 +type ChanceSource struct { + Source string `json:"source"` + Amount int `json:"amount"` + Params json.RawMessage `json:"params,omitempty"` + DailyLimit int `json:"daily_limit,omitempty"` + Unlimited bool `json:"unlimited,omitempty"` +} + +// UnmetReason 是门槛评估结果,作为 GET /config 的 unmet_reasons 元素返回。 +type UnmetReason struct { + Rule string `json:"rule"` + Hint string `json:"hint"` + Current int64 `json:"current,omitempty"` + Required int64 `json:"required,omitempty"` +} diff --git a/internal/model/lottery/registry.go b/internal/model/lottery/registry.go new file mode 100644 index 0000000..c5ebf80 --- /dev/null +++ b/internal/model/lottery/registry.go @@ -0,0 +1,94 @@ +package lottery + +import ( + "context" + "sync" + + "gorm.io/gorm" +) + +// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。 +type defaultRegistry struct { + mu sync.RWMutex + handlers map[string]PrizeHandler +} + +// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。 +func NewRegistry() *defaultRegistry { + return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)} +} + +// Register 挂接一个 handler;重复注册会覆盖(初始化阶段可控,不额外拦截)。 +func (r *defaultRegistry) Register(h PrizeHandler) { + r.mu.Lock() + defer r.mu.Unlock() + r.handlers[h.Type()] = h +} + +func (r *defaultRegistry) Get(prizeType string) (PrizeHandler, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + h, ok := r.handlers[prizeType] + return h, ok +} + +func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) { + if h, ok := r.Get(prizeType); ok { + return h, nil + } + return nil, ErrHandlerNotRegistered +} + +// ---- Stage 1 骨架 handler -------------------------------------------------- +// 真实业务对接在架构师 review 骨架 PR 之后 Day 2-3 补齐。此时命中真实 +// handler 会返回 ErrNotImplemented,让抽奖流程整笔事务回滚,避免误发。 + +// noopHandler 是"谢谢参与"。写记录、不发放,不需要真实业务对接。 +type noopHandler struct{} + +// NewNoopHandler 返回 PrizeTypeNone 的 handler。 +func NewNoopHandler() PrizeHandler { return &noopHandler{} } + +func (noopHandler) Type() string { return PrizeTypeNone } +func (noopHandler) IsAuto() bool { return true } +func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) { + return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil +} +func (noopHandler) ValidateClaim(_ []byte) error { return nil } + +// vpnDurationStubHandler 是订阅时长发放的占位 handler。 +// Day 2-3 需替换为真实实现: +// 1. tx 内 SELECT ... FOR UPDATE 锁 lottery_grant_ledger UNIQUE(external_ref) +// 2. 命中 → 幂等返回;未命中 → INSERT ledger + 调用 UserModel.UpdateSubscribe/InsertSubscribe +// 3. external_ref 用 fmt.Sprintf("lottery:%d:%d", req.ActivityId, req.DrawId) +// 4. 家庭组场景通过 commonLogic.ResolveEntitlementUser 归位到 owner +// 5. 成功后 best-effort 调 NodeModel.ClearServerAllCache + 用户组重算 +type vpnDurationStubHandler struct{} + +// NewVPNDurationStubHandler 返回 PrizeTypeVPNDuration 的骨架 handler。 +func NewVPNDurationStubHandler() PrizeHandler { return &vpnDurationStubHandler{} } + +func (vpnDurationStubHandler) Type() string { return PrizeTypeVPNDuration } +func (vpnDurationStubHandler) IsAuto() bool { return true } +func (vpnDurationStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) { + return DispatchResult{}, ErrNotImplemented +} +func (vpnDurationStubHandler) ValidateClaim(_ []byte) error { return nil } + +// commissionStubHandler 是佣金入账的占位 handler。 +// Day 2-3 需替换为真实实现: +// 1. tx 内 lottery_grant_ledger 幂等检查 +// 2. 调 UserModel.UpdateCommission + common.WriteCommissionLog(同 tx) +// 3. 建议新增 log.CommissionTypeLottery 常量(数值待架构师批), +// 避免把抽奖入账混进 Purchase/Renewal 类型统计 +type commissionStubHandler struct{} + +// NewCommissionStubHandler 返回 PrizeTypeCommission 的骨架 handler。 +func NewCommissionStubHandler() PrizeHandler { return &commissionStubHandler{} } + +func (commissionStubHandler) Type() string { return PrizeTypeCommission } +func (commissionStubHandler) IsAuto() bool { return true } +func (commissionStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) { + return DispatchResult{}, ErrNotImplemented +} +func (commissionStubHandler) ValidateClaim(_ []byte) error { return nil } diff --git a/internal/model/lottery/registry_test.go b/internal/model/lottery/registry_test.go new file mode 100644 index 0000000..6fc300c --- /dev/null +++ b/internal/model/lottery/registry_test.go @@ -0,0 +1,59 @@ +package lottery + +import ( + "context" + "errors" + "testing" + + "gorm.io/gorm" +) + +func TestRegistry_GetMissing(t *testing.T) { + r := NewRegistry() + if _, ok := r.Get("nope"); ok { + t.Fatalf("empty registry should not return handler") + } + if _, err := r.MustGet("nope"); !errors.Is(err, ErrHandlerNotRegistered) { + t.Fatalf("MustGet on missing type should return ErrHandlerNotRegistered, got %v", err) + } +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + r := NewRegistry() + r.Register(NewNoopHandler()) + r.Register(NewVPNDurationStubHandler()) + r.Register(NewCommissionStubHandler()) + + for _, want := range []string{PrizeTypeNone, PrizeTypeVPNDuration, PrizeTypeCommission} { + h, err := r.MustGet(want) + if err != nil { + t.Fatalf("MustGet(%q): %v", want, err) + } + if h.Type() != want { + t.Fatalf("Type mismatch: want %q got %q", want, h.Type()) + } + } +} + +func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) { + h := NewNoopHandler() + if !h.IsAuto() { + t.Fatal("noop must be auto") + } + res, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{}) + if err != nil { + t.Fatalf("noop dispatch err: %v", err) + } + if res.State != DispatchStateAutoClaimed { + t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State) + } +} + +func TestStubHandlers_ReturnNotImplemented(t *testing.T) { + for _, h := range []PrizeHandler{NewVPNDurationStubHandler(), NewCommissionStubHandler()} { + _, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{}) + if !errors.Is(err, ErrNotImplemented) { + t.Fatalf("%s: expected ErrNotImplemented, got %v", h.Type(), err) + } + } +} diff --git a/internal/model/lottery/rule_evaluator.go b/internal/model/lottery/rule_evaluator.go new file mode 100644 index 0000000..4d3f613 --- /dev/null +++ b/internal/model/lottery/rule_evaluator.go @@ -0,0 +1,252 @@ +package lottery + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// 门槛规则类型常量。前端展示时可依据这些 key 组装本地化提示。 +const ( + RuleTypeHasSubscription = "has_subscription" // 需要有活跃订阅(可带 min_days_remaining) + RuleTypeSubscriptionType = "subscription_type" // 订阅套餐必须在 plan_ids 中 + RuleTypeInviteCount = "invite_count" // 邀请人数 ≥ min(可带 window_days,由 ContextBuilder 预算) + RuleTypeTotalRecharge = "total_recharge" // 累计充值 ≥ min_usdt + RuleTypeRegisterDays = "register_days" // 注册天数 ≥ min + RuleTypeUserTag = "user_tag" // 用户标签命中 tags[] 中任一 +) + +const ( + OpAND = "AND" + OpOR = "OR" +) + +// defaultEvaluator 是 RuleEvaluator 的开箱实现。 +type defaultEvaluator struct{} + +// NewRuleEvaluator 返回默认门槛评估器。 +func NewRuleEvaluator() RuleEvaluator { return &defaultEvaluator{} } + +func (e *defaultEvaluator) Evaluate(_ context.Context, tree *EligibilityRule, rc RuleContext) (bool, []UnmetReason, error) { + if tree == nil { + return true, nil, nil + } + unmet := make([]UnmetReason, 0, 4) + passed, err := e.evalNode(tree, rc, &unmet) + if err != nil { + return false, nil, err + } + return passed, unmet, nil +} + +func (e *defaultEvaluator) evalNode(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + if node == nil { + return true, nil + } + // 聚合节点 + if node.Op != "" { + return e.evalGroup(node, rc, unmet) + } + // 叶子节点 + if node.Type == "" { + return true, nil + } + return e.evalLeaf(node, rc, unmet) +} + +func (e *defaultEvaluator) evalGroup(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + op := strings.ToUpper(node.Op) + if len(node.Children) == 0 { + return true, nil + } + switch op { + case OpAND: + allPassed := true + for _, child := range node.Children { + ok, err := e.evalNode(child, rc, unmet) + if err != nil { + return false, err + } + if !ok { + allPassed = false + } + } + return allPassed, nil + case OpOR: + // OR 只收集内部未通过项到一个临时篮子;若整体通过则不冒泡出去。 + anyPassed := false + local := make([]UnmetReason, 0, len(node.Children)) + for _, child := range node.Children { + ok, err := e.evalNode(child, rc, &local) + if err != nil { + return false, err + } + if ok { + anyPassed = true + } + } + if !anyPassed { + *unmet = append(*unmet, local...) + } + return anyPassed, nil + default: + return false, fmt.Errorf("unknown group op %q", node.Op) + } +} + +func (e *defaultEvaluator) evalLeaf(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + switch node.Type { + case RuleTypeHasSubscription: + return e.evalHasSubscription(node, rc, unmet) + case RuleTypeSubscriptionType: + return e.evalSubscriptionType(node, rc, unmet) + case RuleTypeInviteCount: + return e.evalInviteCount(node, rc, unmet) + case RuleTypeTotalRecharge: + return e.evalTotalRecharge(node, rc, unmet) + case RuleTypeRegisterDays: + return e.evalRegisterDays(node, rc, unmet) + case RuleTypeUserTag: + return e.evalUserTag(node, rc, unmet) + default: + return false, fmt.Errorf("unknown leaf rule type %q", node.Type) + } +} + +// ---- 单条规则 ------------------------------------------------------------- + +func (e *defaultEvaluator) evalHasSubscription(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + MinDaysRemaining int64 `json:"min_days_remaining"` + } + if len(node.Params) > 0 { + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("has_subscription params: %w", err) + } + } + if !rc.HasActiveSubscription { + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeHasSubscription, + Hint: "需要有活跃订阅", + }) + return false, nil + } + if params.MinDaysRemaining > 0 { + got := rc.SubscriptionExpiresIn / 86400 + if got < params.MinDaysRemaining { + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeHasSubscription, + Hint: fmt.Sprintf("订阅剩余天数不足,还差 %d 天", params.MinDaysRemaining-got), + Current: got, + Required: params.MinDaysRemaining, + }) + return false, nil + } + } + return true, nil +} + +func (e *defaultEvaluator) evalSubscriptionType(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + PlanIds []int64 `json:"plan_ids"` + } + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("subscription_type params: %w", err) + } + want := make(map[int64]struct{}, len(params.PlanIds)) + for _, id := range params.PlanIds { + want[id] = struct{}{} + } + for _, id := range rc.SubscriptionPlanIds { + if _, ok := want[id]; ok { + return true, nil + } + } + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeSubscriptionType, + Hint: "订阅类型不符合活动要求", + }) + return false, nil +} + +func (e *defaultEvaluator) evalInviteCount(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + Min int64 `json:"min"` + WindowDays int64 `json:"window_days,omitempty"` + } + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("invite_count params: %w", err) + } + if rc.InviteCount >= params.Min { + return true, nil + } + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeInviteCount, + Hint: fmt.Sprintf("还需邀请 %d 人(%d/%d)", params.Min-rc.InviteCount, rc.InviteCount, params.Min), + Current: rc.InviteCount, + Required: params.Min, + }) + return false, nil +} + +func (e *defaultEvaluator) evalTotalRecharge(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + MinUSDT int64 `json:"min_usdt"` + } + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("total_recharge params: %w", err) + } + if rc.TotalRechargeUSDT >= params.MinUSDT { + return true, nil + } + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeTotalRecharge, + Hint: fmt.Sprintf("累计充值不足,还差 %d USDT", params.MinUSDT-rc.TotalRechargeUSDT), + Current: rc.TotalRechargeUSDT, + Required: params.MinUSDT, + }) + return false, nil +} + +func (e *defaultEvaluator) evalRegisterDays(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + Min int64 `json:"min"` + } + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("register_days params: %w", err) + } + if rc.RegisterDays >= params.Min { + return true, nil + } + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeRegisterDays, + Hint: fmt.Sprintf("注册天数不足,还差 %d 天", params.Min-rc.RegisterDays), + Current: rc.RegisterDays, + Required: params.Min, + }) + return false, nil +} + +func (e *defaultEvaluator) evalUserTag(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) { + var params struct { + Tags []string `json:"tags"` + } + if err := json.Unmarshal(node.Params, ¶ms); err != nil { + return false, fmt.Errorf("user_tag params: %w", err) + } + want := make(map[string]struct{}, len(params.Tags)) + for _, t := range params.Tags { + want[t] = struct{}{} + } + for _, t := range rc.UserTags { + if _, ok := want[t]; ok { + return true, nil + } + } + *unmet = append(*unmet, UnmetReason{ + Rule: RuleTypeUserTag, + Hint: "用户标签不符合活动要求", + }) + return false, nil +} diff --git a/internal/model/lottery/rule_evaluator_test.go b/internal/model/lottery/rule_evaluator_test.go new file mode 100644 index 0000000..6522c98 --- /dev/null +++ b/internal/model/lottery/rule_evaluator_test.go @@ -0,0 +1,193 @@ +package lottery + +import ( + "context" + "encoding/json" + "testing" +) + +func mustParams(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal params: %v", err) + } + return b +} + +func TestEvaluator_HasSubscription(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Type: RuleTypeHasSubscription, + Params: mustParams(t, map[string]int64{"min_days_remaining": 7}), + } + + // 没订阅 + ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{}) + if err != nil { + t.Fatalf("eval err: %v", err) + } + if ok || len(unmet) != 1 || unmet[0].Rule != RuleTypeHasSubscription { + t.Fatalf("expected fail on no sub, got ok=%v unmet=%+v", ok, unmet) + } + + // 有订阅但剩余不足 + ok, unmet, err = ev.Evaluate(context.Background(), tree, RuleContext{ + HasActiveSubscription: true, + SubscriptionExpiresIn: 3 * 86400, + }) + if err != nil { + t.Fatalf("eval err: %v", err) + } + if ok { + t.Fatalf("expected fail on 3 days < 7 required, got pass") + } + if len(unmet) != 1 || unmet[0].Required != 7 || unmet[0].Current != 3 { + t.Fatalf("unmet mismatch: %+v", unmet) + } + + // 有订阅剩余足够 + ok, _, err = ev.Evaluate(context.Background(), tree, RuleContext{ + HasActiveSubscription: true, + SubscriptionExpiresIn: 30 * 86400, + }) + if err != nil || !ok { + t.Fatalf("expected pass on 30 days, got ok=%v err=%v", ok, err) + } +} + +func TestEvaluator_InviteCount(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Type: RuleTypeInviteCount, + Params: mustParams(t, map[string]int64{"min": 3}), + } + ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 1}) + if err != nil || ok { + t.Fatalf("expected fail, got ok=%v err=%v", ok, err) + } + if unmet[0].Current != 1 || unmet[0].Required != 3 { + t.Fatalf("expected current=1 required=3, got %+v", unmet[0]) + } + + ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 3}) + if !ok { + t.Fatalf("expected pass with InviteCount=3") + } +} + +func TestEvaluator_ANDShort(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Op: OpAND, + Children: []*EligibilityRule{ + {Type: RuleTypeHasSubscription}, + {Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})}, + }, + } + ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{ + HasActiveSubscription: true, + InviteCount: 1, + }) + if ok { + t.Fatalf("expected AND to fail") + } + // AND 需要收集全部未通过项(这里只有 1 条) + if len(unmet) != 1 || unmet[0].Rule != RuleTypeInviteCount { + t.Fatalf("expected 1 unmet (invite_count), got %+v", unmet) + } +} + +func TestEvaluator_ORPassIgnoresChildUnmet(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Op: OpOR, + Children: []*EligibilityRule{ + {Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 100})}, + {Type: RuleTypeHasSubscription}, // 会通过 + }, + } + ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{ + HasActiveSubscription: true, + InviteCount: 0, + }) + if !ok { + t.Fatalf("expected OR to pass because one child passes") + } + if len(unmet) != 0 { + t.Fatalf("OR pass should hide child failures, got %+v", unmet) + } +} + +func TestEvaluator_ORFailBubblesAllChildren(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Op: OpOR, + Children: []*EligibilityRule{ + {Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})}, + {Type: RuleTypeHasSubscription}, + }, + } + ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{ + InviteCount: 1, + }) + if ok { + t.Fatalf("expected OR to fail") + } + if len(unmet) != 2 { + t.Fatalf("expected 2 unmet reasons on OR fail, got %+v", unmet) + } +} + +func TestEvaluator_SubscriptionType(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Type: RuleTypeSubscriptionType, + Params: mustParams(t, map[string][]int64{"plan_ids": {10, 20}}), + } + ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{ + SubscriptionPlanIds: []int64{20}, + }) + if !ok { + t.Fatalf("expected pass when user plan matches") + } + ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{ + SubscriptionPlanIds: []int64{99}, + }) + if ok { + t.Fatalf("expected fail when user plan not in whitelist") + } +} + +func TestEvaluator_UserTag(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{ + Type: RuleTypeUserTag, + Params: mustParams(t, map[string][]string{"tags": {"vip", "beta"}}), + } + ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"beta"}}) + if !ok { + t.Fatalf("expected pass when any tag matches") + } + ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"foo"}}) + if ok { + t.Fatalf("expected fail when no tags match") + } +} + +func TestEvaluator_NilTreeAlwaysPasses(t *testing.T) { + ev := NewRuleEvaluator() + ok, unmet, err := ev.Evaluate(context.Background(), nil, RuleContext{}) + if err != nil || !ok || len(unmet) != 0 { + t.Fatalf("nil tree should always pass; got ok=%v unmet=%+v err=%v", ok, unmet, err) + } +} + +func TestEvaluator_UnknownRuleType(t *testing.T) { + ev := NewRuleEvaluator() + tree := &EligibilityRule{Type: "no_such_rule"} + _, _, err := ev.Evaluate(context.Background(), tree, RuleContext{}) + if err == nil { + t.Fatalf("expected error on unknown rule type") + } +} diff --git a/internal/model/lottery/service.go b/internal/model/lottery/service.go new file mode 100644 index 0000000..fcacf2e --- /dev/null +++ b/internal/model/lottery/service.go @@ -0,0 +1,122 @@ +// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、 +// 加权随机选奖、发奖 handler 抽象。 +// +// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、 +// 邀请钩子)留到架构师 review 骨架 PR 之后再补。 +package lottery + +import ( + "context" + "errors" + + "gorm.io/gorm" +) + +// ---- 门槛规则引擎 ---------------------------------------------------------- + +// RuleContext 是评估门槛规则时可见的用户上下文。构造时应做一次批量 +// 读,避免每条子规则各自查库。 +type RuleContext struct { + UserId int64 + Now int64 // Unix seconds + + // 下列字段由 RuleContextBuilder 填充。规则实现只读,不写。 + HasActiveSubscription bool + SubscriptionExpiresIn int64 // seconds until expiry; 0 if no active sub + SubscriptionPlanIds []int64 + InviteCount int64 // 若规则限定 window_days,则调用方需自行按窗口预计算 + TotalRechargeUSDT int64 // 已充值总额,单位与业务侧一致 + RegisterDays int64 + UserTags []string +} + +// RuleEvaluator 评估一棵门槛规则树,返回是否通过 + 未通过项。 +// 实现是纯计算,不做任何 DB 写。 +type RuleEvaluator interface { + Evaluate(ctx context.Context, tree *EligibilityRule, rc RuleContext) (passed bool, unmet []UnmetReason, err error) +} + +// ---- 加权随机选奖 ---------------------------------------------------------- + +// WeightedPicker 从奖池中按 weight 加权抽一次。实现应使用注入的 rand 源, +// 便于测试;weight 为 0 的奖品视作不参与随机(可用于挂出但不发放)。 +type WeightedPicker interface { + // Pick 从 candidates 中返回一个索引 i;若累计权重为 0 返回 ErrEmptyPool。 + // 调用方在事务外先做快照,事务内再对返回的 candidates[i] 做库存乐观扣减。 + Pick(candidates []Prize) (int, error) +} + +// ErrEmptyPool 表示奖池累计权重为 0,无法完成一次随机。 +var ErrEmptyPool = errors.New("lottery: empty prize pool") + +// ---- 次数入账 / 消耗 ------------------------------------------------------- + +// ChanceService 处理"用户抽奖次数账户",为触发源提供幂等入账、为抽奖流程提供 +// 事务内原子扣减。 +type ChanceService interface { + // Grant 记录一次次数入账;幂等键 = (activityId, source, sourceRef)。 + // 已存在的 sourceRef 视为幂等命中,返回 nil 不重复发放。 + Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error + + // Consume 在事务内扣减一次次数(SELECT ... FOR UPDATE 锁 chance_balance), + // 返回扣减后剩余次数。剩余为 0 时返回 ErrNoChances。 + Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (remaining int64, err error) + + // Query 返回用户当前剩余次数(非事务,用于 GET /config 展示)。 + Query(ctx context.Context, userId, activityId int64) (remaining int64, err error) +} + +// ErrNoChances 表示用户在该活动下已无剩余抽奖次数。 +var ErrNoChances = errors.New("lottery: no chances remaining") + +// ---- 发奖 Handler --------------------------------------------------------- + +// DispatchRequest 是 PrizeHandler.Dispatch 的输入。事务由调用方开启并传入, +// handler 在同 tx 内完成外部账本写入 + 业务侧发放。 +type DispatchRequest struct { + UserId int64 + ActivityId int64 + DrawId int64 + Prize Prize // 当前奖品(含 Config JSON) + Snapshot PrizeSnapshot // 抽奖时刻快照 +} + +// DispatchResult 是发奖结果。 +type DispatchResult struct { + // State 会写入 lottery_draw.dispatch_state。 + State string + // Message 供前端展示("已加到订阅"/"佣金到账 3 USDT" 等)。 + Message string +} + +// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示 +// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现 +// IsAuto=true 的三种)。 +// +// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write, +// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。 +type PrizeHandler interface { + Type() string + IsAuto() bool + // Dispatch 在调用方的事务内执行;返回结果或错误。 + // 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。 + Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error) + // ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的 + // auto handler 直接返回 nil 即可。 + ValidateClaim(raw []byte) error +} + +// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前 +// 若不慎命中真实 handler 会立即失败,避免误发。 +var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business") + +// Registry 是 type → PrizeHandler 的路由表。抽奖服务只依赖此接口, +// 具体 handler 由 initialize 阶段注入。 +type Registry interface { + Get(prizeType string) (PrizeHandler, bool) + // MustGet 在类型未注册时返回 ErrHandlerNotRegistered。 + MustGet(prizeType string) (PrizeHandler, error) +} + +// ErrHandlerNotRegistered 表示奖品类型没有对应 handler。 +var ErrHandlerNotRegistered = errors.New("lottery: prize handler not registered") diff --git a/internal/model/lottery/weighted_picker.go b/internal/model/lottery/weighted_picker.go new file mode 100644 index 0000000..4cb2973 --- /dev/null +++ b/internal/model/lottery/weighted_picker.go @@ -0,0 +1,66 @@ +package lottery + +import ( + "math/rand" + "sync" + "time" +) + +// weightedPicker 是 WeightedPicker 的默认实现。使用累计权重 O(log n) 二分选取。 +// 注入的 rand.Source 允许测试固定种子。 +type weightedPicker struct { + mu sync.Mutex + rng *rand.Rand +} + +// NewWeightedPicker 返回默认加权选取器。传 seed=0 使用当前时间纳秒。 +func NewWeightedPicker(seed int64) WeightedPicker { + if seed == 0 { + seed = time.Now().UnixNano() + } + return &weightedPicker{ + rng: rand.New(rand.NewSource(seed)), + } +} + +// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重 +// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。 +// +// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、 +// 不需要预分配,并对小池(<20 项)足够快。 +func (p *weightedPicker) Pick(candidates []Prize) (int, error) { + if len(candidates) == 0 { + return 0, ErrEmptyPool + } + var total int64 + for _, c := range candidates { + if c.Weight > 0 { + total += int64(c.Weight) + } + } + if total == 0 { + return 0, ErrEmptyPool + } + + p.mu.Lock() + roll := p.rng.Int63n(total) + p.mu.Unlock() + + var cum int64 + for i, c := range candidates { + if c.Weight <= 0 { + continue + } + cum += int64(c.Weight) + if roll < cum { + return i, nil + } + } + // 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。 + for i := len(candidates) - 1; i >= 0; i-- { + if candidates[i].Weight > 0 { + return i, nil + } + } + return 0, ErrEmptyPool +} diff --git a/internal/model/lottery/weighted_picker_test.go b/internal/model/lottery/weighted_picker_test.go new file mode 100644 index 0000000..a303098 --- /dev/null +++ b/internal/model/lottery/weighted_picker_test.go @@ -0,0 +1,76 @@ +package lottery + +import ( + "testing" +) + +func TestWeightedPicker_EmptyPool(t *testing.T) { + p := NewWeightedPicker(42) + if _, err := p.Pick(nil); err != ErrEmptyPool { + t.Fatalf("expected ErrEmptyPool for nil pool, got %v", err) + } + if _, err := p.Pick([]Prize{{Weight: 0}, {Weight: 0}}); err != ErrEmptyPool { + t.Fatalf("expected ErrEmptyPool when all weights are 0, got %v", err) + } +} + +func TestWeightedPicker_SkipsZeroWeight(t *testing.T) { + p := NewWeightedPicker(1) + pool := []Prize{ + {Id: 1, Weight: 0}, // 不参与 + {Id: 2, Weight: 100}, // 独占权重 + {Id: 3, Weight: 0}, // 不参与 + } + for i := 0; i < 200; i++ { + idx, err := p.Pick(pool) + if err != nil { + t.Fatalf("Pick err: %v", err) + } + if idx != 1 { + t.Fatalf("expected idx 1 (only positive weight), got %d", idx) + } + } +} + +func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) { + p := NewWeightedPicker(2026) + pool := []Prize{ + {Id: 10, Weight: 10}, // 10/60 ≈ 16.67% + {Id: 20, Weight: 20}, // 20/60 ≈ 33.33% + {Id: 30, Weight: 30}, // 30/60 = 50% + } + const trials = 60000 + counts := make(map[int]int) + for i := 0; i < trials; i++ { + idx, err := p.Pick(pool) + if err != nil { + t.Fatalf("Pick err: %v", err) + } + counts[idx]++ + } + // 允许 ±2% 偏差 + expect := []float64{10.0 / 60, 20.0 / 60, 30.0 / 60} + for i, e := range expect { + got := float64(counts[i]) / float64(trials) + if got < e-0.02 || got > e+0.02 { + t.Fatalf("prize %d: expected %.4f (±0.02), got %.4f", i, e, got) + } + } +} + +func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) { + pool := []Prize{ + {Id: 1, Weight: 1}, + {Id: 2, Weight: 1}, + {Id: 3, Weight: 1}, + } + a := NewWeightedPicker(7) + b := NewWeightedPicker(7) + for i := 0; i < 20; i++ { + ai, _ := a.Pick(pool) + bi, _ := b.Pick(pool) + if ai != bi { + t.Fatalf("iter %d: seed 7 diverged: a=%d b=%d", i, ai, bi) + } + } +}