diff --git a/initialize/migrate/database/02156_lottery_stage1.up.sql b/initialize/migrate/database/02156_lottery_stage1.up.sql index 00b3eea..3e440d9 100644 --- a/initialize/migrate/database/02156_lottery_stage1.up.sql +++ b/initialize/migrate/database/02156_lottery_stage1.up.sql @@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS `lottery_activity` ( `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)', + `grid_size` TINYINT NOT NULL DEFAULT 8 COMMENT '前端九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格;老 schema 是 9)', `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', diff --git a/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.down.sql b/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.down.sql new file mode 100644 index 0000000..c028565 --- /dev/null +++ b/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.down.sql @@ -0,0 +1,4 @@ +-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9 +-- +-- 与 up.sql 对称,只回退默认值,不动数据。 +ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9; diff --git a/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.up.sql b/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.up.sql new file mode 100644 index 0000000..f059ee4 --- /dev/null +++ b/initialize/migrate/database/02160_lottery_activity_grid_size_default_8.up.sql @@ -0,0 +1,15 @@ +-- 02160 抽奖 Stage 2 F8:lottery_activity.grid_size 默认值从 9 改成 8 +-- +-- 前端与产品对齐后确认布局 A:3×3 挖中心 → 中心是"点击抽奖"按钮(不是奖品格), +-- 其余 8 格挂奖品。因此 grid_size 的默认值应为 8,不再是 9。 +-- +-- 兼容性: +-- * up.sql 的 CREATE TABLE 已在 02156 里跑过,MySQL 的 CREATE TABLE IF NOT EXISTS +-- 不会改动既存表结构。所以老部署的 lottery_activity.grid_size 默认值仍是 9, +-- 必须用一条独立的 ALTER 迁移把默认值改过来。 +-- * 已有数据(grid_size=9 的老活动)不动 —— ALTER DEFAULT 只影响新插入行且未提供 +-- grid_size 的场景;Go 侧 admin/lottery.go 的兜底也已配套改成 8。 +-- +-- 幂等:ALTER COLUMN ... SET DEFAULT 在 MySQL 8.0+ 是幂等的(重复执行等值 SET +-- 不会报错),重跑安全。 +ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 8; diff --git a/internal/logic/admin/lottery/lottery.go b/internal/logic/admin/lottery/lottery.go index ca0d634..7c5ba90 100644 --- a/internal/logic/admin/lottery/lottery.go +++ b/internal/logic/admin/lottery/lottery.go @@ -87,7 +87,9 @@ func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdmi UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock), } if activity.GridSize <= 0 { - activity.GridSize = 9 + // HIF-4 F8: 布局 A 3×3 挖中心 → 8 个奖品格。前端约定中心是"点击抽奖"按钮, + // 不渲染为奖品;后台仍允许挂 slot=4 但前端会忽略。 + activity.GridSize = 8 } body, _ := json.Marshal(req) err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { diff --git a/internal/logic/lottery/draw/service.go b/internal/logic/lottery/draw/service.go index 0d645d9..9e5a90a 100644 --- a/internal/logic/lottery/draw/service.go +++ b/internal/logic/lottery/draw/service.go @@ -38,6 +38,7 @@ import ( "github.com/perfect-panel/server/internal/logic/lottery/handler" "github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/pkg/limit" + "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "gorm.io/gorm" ) @@ -601,15 +602,24 @@ func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) { return &tree, nil } +// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。 +// +// HIF-4 F10:msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文 +// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致 +// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种 +// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。 func wrapInternal(err error) error { if err == nil { return nil } - // Preserve already-coded errors. + // Preserve already-coded errors (their msg 是设计过的对外文案,不动). if _, ok := err.(*xerr.CodeError); ok { return err } - return xerr.NewErrCodeMsg(xerr.LotteryInternalError, err.Error()) + // 内部细节走日志,供运维/后端排查;err.Error() 不外传。 + logger.WithContext(context.Background()).Error("[lottery draw internal error]", + logger.Field("error", err.Error())) + return xerr.NewErrCode(xerr.LotteryInternalError) } // ---- Rate limiter production wiring ---------------------------------------- diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go index c6fa161..f23d831 100644 --- a/internal/logic/lottery/draw/service_test.go +++ b/internal/logic/lottery/draw/service_test.go @@ -514,6 +514,52 @@ func errAsCode(err error) (uint32, bool) { return 0, false } +// TestWrapInternal_ScrubsErrorDetailsFromMsg is the HIF-4 F10 regression guard. +// +// wrapInternal 之前把 err.Error() 直接塞 xerr.CodeError.Msg,导致对外响应 +// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} +// 把 DB 结构/表名/内部包路径全部泄露给 app 端。F10 修法:细节走日志,msg 只 +// 带通用文案。此测试锁死:"wrapInternal(任意非 CodeError 的原生错误) 返回的 +// CodeError.Msg 不能等于原始 err.Error()"。 +func TestWrapInternal_ScrubsErrorDetailsFromMsg(t *testing.T) { + sensitive := errors.New("insert lottery_claim for draw 15: Error 3140 (22032): Invalid JSON text: The document is empty") + wrapped := wrapInternal(sensitive) + if wrapped == nil { + t.Fatal("wrapInternal returned nil for non-nil error") + } + var ce *xerr.CodeError + if !errors.As(wrapped, &ce) { + t.Fatalf("expected *xerr.CodeError, got %T", wrapped) + } + if ce.GetErrCode() != xerr.LotteryInternalError { + t.Fatalf("expected code=%d, got %d", xerr.LotteryInternalError, ce.GetErrCode()) + } + if strings.Contains(ce.GetErrMsg(), "lottery_claim") || + strings.Contains(ce.GetErrMsg(), "3140") || + strings.Contains(ce.GetErrMsg(), "Invalid JSON") { + t.Fatalf("F10 regression: internal error detail leaked to msg: %q", ce.GetErrMsg()) + } + // 反过来断言:msg 应该是标准文案(xerr.MapErrMsg 查表得到) + if ce.GetErrMsg() != xerr.MapErrMsg(xerr.LotteryInternalError) { + t.Fatalf("expected generic msg %q, got %q", xerr.MapErrMsg(xerr.LotteryInternalError), ce.GetErrMsg()) + } +} + +// TestWrapInternal_PreservesCodeErrors 副断言:已经是 xerr.CodeError 的错误 +// 不能被 wrap 掉(它们的 msg 是设计过的对外文案,比如 4001/4002/4009)。 +func TestWrapInternal_PreservesCodeErrors(t *testing.T) { + coded := xerr.NewErrCode(xerr.LotteryNoChances) + wrapped := wrapInternal(coded) + var ce *xerr.CodeError + if !errors.As(wrapped, &ce) { + t.Fatalf("expected *xerr.CodeError, got %T", wrapped) + } + if ce.GetErrCode() != xerr.LotteryNoChances { + t.Fatalf("F10 side-effect: coded error was rewrapped; got code %d instead of %d", + ce.GetErrCode(), xerr.LotteryNoChances) + } +} + // TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard // (kept from PR E — must survive Stage 2 rebase). // diff --git a/internal/model/lottery/model.go b/internal/model/lottery/model.go index 2b55a56..e5c2371 100644 --- a/internal/model/lottery/model.go +++ b/internal/model/lottery/model.go @@ -77,7 +77,7 @@ type Activity struct { 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:九宫格数量"` + GridSize int `gorm:"type:tinyint;not null;default:8;comment:九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格)"` 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:未达门槛策略"`