修复(#4): 抽奖 8 宫格默认值 + 100500 msg 脱敏

Closes part of HIF-4 (Stage 2 前端集成反馈 F8 + F10)

F8 (P1): Activity.GridSize 默认值 9 → 8
- migration 02160 ALTER DEFAULT 8(幂等)
- 02156 up.sql 注释同步更新
- admin/lottery.go 兜底值 9 → 8
- 布局 A:3x3 挖中心,中心是抽奖按钮不是奖品

F10 (P1): wrapInternal msg 脱敏
- 内部错误 err.Error() 只写日志,不外传
- 用户端 msg 只带通用文案 (xerr.MapErrMsg lookup)
- 回归护栏:TestWrapInternal_ScrubsErrorDetailsFromMsg + TestWrapInternal_PreservesCodeErrors

CI 全绿;无 API 契约变更;F9 独立在 PR #45 处理
This commit is contained in:
2026-07-12 19:55:25 -07:00
committed by GitHub
parent 980e5adb90
commit cce147108c
7 changed files with 82 additions and 5 deletions
@@ -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',
@@ -0,0 +1,4 @@
-- 02160 down: 恢复 lottery_activity.grid_size 默认值到 9
--
-- 与 up.sql 对称,只回退默认值,不动数据。
ALTER TABLE `lottery_activity` ALTER COLUMN `grid_size` SET DEFAULT 9;
@@ -0,0 +1,15 @@
-- 02160 抽奖 Stage 2 F8lottery_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;
+3 -1
View File
@@ -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 {
+12 -2
View File
@@ -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 F10msg 字段只带通用文案("抽奖服务暂时不可用"),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 ----------------------------------------
@@ -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).
//
+1 -1
View File
@@ -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:未达门槛策略"`