修复(#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:
@@ -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 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----------------------------------------
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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:未达门槛策略"`
|
||||
|
||||
Reference in New Issue
Block a user