feat(abtest): 扩展 A/B 测试至首页弹窗、商品按钮、签到页三个场景

注册 homepage_popup / product_button / signin_reward 三个实验变量,
后端在订单支付、积分兑换、签到成功时自动上报转化事件;
小程序三页面对接 abAssign 动态文案,首页弹窗新增 POST /api/mp/ab/event
端点供前端触发 popup_action 转化事件(事件名由注册表校验,防注入)。
This commit is contained in:
2026-09-14 09:22:22 +08:00
parent 36e5377052
commit 8a727d5677
19 changed files with 238 additions and 7 deletions
+5
View File
@@ -289,6 +289,11 @@ func main() {
WithV8(v8Engine).
WithABTest(abtestSvc).
WithAlerter(drawboxAlerter)
// A/B 测试引擎注入:订单支付/积分兑换/签到 成功后上报转化事件。
orderSvc.ABTest = abtestSvc
MallService.ABTest = abtestSvc
checkinSvc.ABTest = abtestSvc
signinLotSvc.ABTest = abtestSvc
// 命运盒子任务中心(Task13):独立服务,供签到/邀请服务按可选依赖挂钩调用。
drawboxTaskSvc := drawbox.NewTaskService(db, drawboxCfgSvc, ledger)
+39 -3
View File
@@ -12,9 +12,9 @@ import (
// ABTestHandler 小程序端 A/B 测试分组分配处理器。
//
// 仅暴露一个只读接口:拉取当前用户在指定变量下的分组配置。
// 转化事件由后端服务在业务链路中自行记录(如 drawbox draw → abtest.RecordEvent),
// 不开放给小程序端主动上报,避免刷量污染统计。
// 提供分组分配(Assign)与前端触发型转化事件上报(RecordConversion)。
// RecordConversion 仅接受注册表中已定义的变量,事件名取自注册表而非客户端传入,
// 防止任意事件名注入。
type ABTestHandler struct {
Svc *abtest.Service
// DB 用于组装分流主体(openid / 新老用户口径)。nil 时降级为仅按 uid+IP 分流。
@@ -63,6 +63,42 @@ func (h *ABTestHandler) Assign(c *gin.Context) {
})
}
// RecordConversion 前端触发型转化事件上报。
//
// POST /api/mp/ab/event
// Body: {"variable": "homepage_popup"}
//
// 事件名从注册表取(EventName 字段),客户端只指定变量,不可自定义事件名。
// 仅用于无自然后端触发点的 UI 交互(如弹窗按钮点击)。
func (h *ABTestHandler) RecordConversion(c *gin.Context) {
if h.Svc == nil {
response.OK(c, nil)
return
}
var req struct {
Variable string `json:"variable"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Variable == "" {
response.Fail(c, response.CodeBadRequest, "missing variable")
return
}
spec := abtest.FindVariable(req.Variable)
if spec == nil || spec.EventName == "" {
response.Fail(c, response.CodeBadRequest, "unknown variable or no event defined")
return
}
uid := c.GetUint64("uid")
if uid == 0 {
response.Fail(c, response.CodeUnauthorized, "login required")
return
}
if err := h.Svc.RecordEvent(c.Request.Context(), uid, spec.Key, spec.EventName); err != nil {
response.FailErr(c, response.CodeInternal, err)
return
}
response.OK(c, nil)
}
// buildSubject 从 JWT、用户表与请求上下文组装分流主体。
// 用户行查询失败时相关维度留空(openid 分桶/新老定向自然不命中),不影响 user_id 分桶。
func (h *ABTestHandler) buildSubject(c *gin.Context) abtest.Subject {
+1
View File
@@ -446,6 +446,7 @@ func New(mode string, d Deps) *gin.Engine {
// 未注入引擎(Deps.MpABTest == nil)时不挂载,前端调用将 404 → 走默认逻辑。
if d.MpABTest != nil {
mpG.GET("/ab/assign", d.MpABTest.Assign)
mpG.POST("/ab/event", d.MpABTest.RecordConversion)
}
adminG.GET("/cockpit/overview", middleware.RequirePerm(d.DB, "cockpit:read"), d.AdminSprint6.Cockpit)
+44 -2
View File
@@ -49,8 +49,13 @@ type VariableSpec struct {
Params []ParamSpec `json:"params"`
}
// variables 注册表本体。第一版聚焦命运盒子链路——它是当前唯一具备
// 转化事件闭环(drawbox draw → ab_events)的业务点。
// variables 注册表本体。
//
// 已注册变量:
// - drawbox:命运盒子页面文案(转化事件 draw)
// - homepage_popup:首页弹窗文案(转化事件 popup_action)
// - product_button:商品详情页按钮文案(转化事件 purchase)
// - signin_reward:签到页奖励展示(转化事件 checkin)
var variables = []VariableSpec{
{
Key: "drawbox",
@@ -65,6 +70,43 @@ var variables = []VariableSpec{
{Key: "show_footer_rule", Label: "展示底部玩法说明", Type: ParamBoolean, Default: true},
},
},
{
Key: "homepage_popup",
Label: "首页弹窗文案",
Description: "首页弹窗的标题、描述与按钮文案。转化事件:用户点击弹窗按钮进入活动(popup_action)。",
ConsumedBy: "小程序 pages/index/index",
EventName: "popup_action",
Params: []ParamSpec{
{Key: "title", Label: "弹窗标题", Type: ParamString, Default: "今日好运推荐", MaxLength: 12},
{Key: "description", Label: "弹窗描述", Type: ParamString, Default: "精选好物等你来抽!", MaxLength: 30},
{Key: "button_text", Label: "按钮文案", Type: ParamString, Default: "立即参与", MaxLength: 8, Placeholder: "如 去看看 / 马上抽"},
{Key: "show_popup", Label: "是否展示弹窗", Type: ParamBoolean, Default: true},
},
},
{
Key: "product_button",
Label: "商品详情页按钮",
Description: "商品详情页购买/兑换按钮与紧迫感文案。转化事件:用户完成购买或积分兑换(purchase)。",
ConsumedBy: "小程序 pages/mall/detail/index",
EventName: "purchase",
Params: []ParamSpec{
{Key: "buy_button_text", Label: "现金购买按钮文案", Type: ParamString, Default: "立即购买", MaxLength: 8, Placeholder: "如 马上抢 / 立即下单"},
{Key: "exchange_button_text", Label: "积分兑换按钮文案", Type: ParamString, Default: "积分兑换", MaxLength: 8, Placeholder: "如 兑换好礼 / 立即兑换"},
{Key: "urgency_text", Label: "紧迫感提示文案", Type: ParamString, Default: "", MaxLength: 20, Placeholder: "如 限时特惠 / 仅剩XX件(空串不展示)"},
},
},
{
Key: "signin_reward",
Label: "签到页奖励展示",
Description: "签到页奖励标题与连续签到描述文案。转化事件:用户完成一次签到(checkin)。",
ConsumedBy: "小程序 pages/signin-lottery/detail/index",
EventName: "checkin",
Params: []ParamSpec{
{Key: "reward_title", Label: "奖励标题", Type: ParamString, Default: "签到领积分", MaxLength: 10, Placeholder: "如 每日签到 / 打卡赢好礼"},
{Key: "streak_description", Label: "连续签到描述", Type: ParamString, Default: "连续签到奖励更多", MaxLength: 20},
{Key: "show_streak_bonus", Label: "展示连续签到加成", Type: ParamBoolean, Default: true},
},
},
}
// Variables 返回注册表全量变量定义(管理后台下拉与动态表单数据源)。
+8
View File
@@ -49,6 +49,10 @@ type Service struct {
Ledger *points.Ledger
// Risk 前置风控判定器(可选);积分/商城兑换前调用,命中拦截返回错误。nil 时放行。
Risk RiskChecker
// ABTest A/B 测试引擎(可选);积分兑换成功后上报 "purchase" 转化事件,nil 时跳过。
ABTest interface {
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
}
}
// NewService 创建商城服务
@@ -705,6 +709,10 @@ func (s *Service) ExchangeProduct(ctx context.Context, mpUserID, productID, addr
if err != nil {
return nil, err
}
// A/B 测试:积分兑换成功后上报 "purchase" 转化事件(商品详情页按钮实验)。
if s.ABTest != nil {
_ = s.ABTest.RecordEvent(context.Background(), mpUserID, "product_button", "purchase")
}
return out, nil
}
+12
View File
@@ -117,6 +117,10 @@ type Service struct {
Promoter PromoterCommissionRecorder
// PointsRecharger 积分充值入账器(V11.0 §6.9,可选);支付成功后积分入账,nil 时不处理 points_recharge 订单。
PointsRecharger PointsRecharger
// ABTest A/B 测试引擎(可选);支付成功后上报 "purchase" 转化事件,nil 时跳过。
ABTest interface {
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
}
}
// PromoterCommissionRecorder 推广员佣金记录接口(由 service/promoter.Service 实现)。
@@ -557,6 +561,10 @@ func (s *Service) markPaid(orderNo, transactionID string, expectedAmountFen int6
return err
}
s.publishOrderPaid(paidOrder)
// A/B 测试:支付成功后上报 "purchase" 转化事件(商品详情页按钮实验)。
if s.ABTest != nil && paidOrder != nil {
_ = s.ABTest.RecordEvent(context.Background(), paidOrder.MpUserID, "product_button", "purchase")
}
return nil
}
@@ -599,6 +607,10 @@ func (s *Service) ManualMarkPaid(orderID uint64) error {
return err
}
s.publishOrderPaid(paidOrder)
// A/B 测试:人工标记支付成功后上报 "purchase" 转化事件。
if s.ABTest != nil && paidOrder != nil {
_ = s.ABTest.RecordEvent(context.Background(), paidOrder.MpUserID, "product_button", "purchase")
}
return nil
}
+8
View File
@@ -39,6 +39,10 @@ type CheckinService struct {
// 选用直调而非 EventBus 的理由:避免引入新事件名 + 可选依赖空时静默跳过,
// 失败仅记日志、不阻塞签到主流程(参考下方 Checkin 的调用注释)。
DrawboxGranter DrawboxTaskGranter
// ABTest A/B 测试引擎(可选);签到成功后上报 "checkin" 转化事件,nil 时跳过。
ABTest interface {
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
}
}
// DrawboxTaskGranter 任务中心发放抽象接口,由 drawbox.TaskService 实现。
@@ -150,6 +154,10 @@ func (s *CheckinService) Checkin(ctx context.Context, userID uint64) (*CheckinRe
}
}
}
// A/B 测试:签到成功后上报 "checkin" 转化事件(签到页奖励展示实验)。
if s.ABTest != nil {
_ = s.ABTest.RecordEvent(context.Background(), userID, "signin_reward", "checkin")
}
return &CheckinResult{
ContinuousDays: streak,
Reward: reward,
@@ -85,6 +85,10 @@ func (s *Service) Checkin(ctx context.Context, lotteryID, userID uint64) (*Check
if err != nil {
return nil, err
}
// A/B 测试:签到成功后上报 "checkin" 转化事件(签到页奖励展示实验)。
if s.ABTest != nil {
_ = s.ABTest.RecordEvent(context.Background(), userID, "signin_reward", "checkin")
}
return &out, nil
}
@@ -1,6 +1,7 @@
package signin_lottery
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -27,6 +28,10 @@ type Service struct {
// Bus 积分事件总线(可空):开奖生成中奖记录后发布 prize.won / prize.await_address。
// 为 nil 时跳过发布(保持向后兼容,便于测试)。
Bus *points.EventBus
// ABTest A/B 测试引擎(可选);签到成功后上报 "checkin" 转化事件,nil 时跳过。
ABTest interface {
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
}
}
func NewService(db *gorm.DB, ledger *points.Ledger) *Service {
+29
View File
@@ -14,6 +14,7 @@ import { daily666Api } from '../../utils/daily666-api'
import { walletApi } from '../../utils/wallet-api'
import { assistApi } from '../../utils/assist-api'
import { formatDrawTimeText, fenToYuan } from '../../utils/format'
import { abAssign, recordConversion } from '../../utils/ab-api'
interface MpUser {
id: number
@@ -121,6 +122,11 @@ Page({
{ key: 'welfare', label: '逛福利', icon: '🎁', undone: false, path: '/pages/tasks/index/index' },
{ key: 'invite', label: '邀请', icon: '👥', undone: false, path: '/pages/tasks/index/index' },
] as { key: string; label: string; icon: string; undone: boolean; path: string }[],
// A/B 实验:首页弹窗文案(homepage_popup)
popupTitle: '今日好运推荐',
popupDescription: '精选好物等你来抽!',
popupButtonText: '立即参与',
showPromoPopup: false,
},
// 倒计时定时器
@@ -178,6 +184,8 @@ Page({
} catch (e) {
// storage 异常不影响主页加载
}
this.loadAbConfig()
},
// 开屏广告关闭回调;同值守卫避免重复 setData 与组件 observer 形成回环
@@ -187,6 +195,27 @@ Page({
}
},
async loadAbConfig() {
const res = await abAssign('homepage_popup')
const p = (res && res.assigned && res.payload) || {}
const patch: Record<string, any> = {}
if (typeof p.title === 'string' && p.title) patch.popupTitle = p.title.slice(0, 12)
if (typeof p.description === 'string' && p.description) patch.popupDescription = p.description.slice(0, 30)
if (typeof p.button_text === 'string' && p.button_text) patch.popupButtonText = p.button_text.slice(0, 8)
if (typeof p.show_popup === 'boolean') patch.showPromoPopup = p.show_popup
if (Object.keys(patch).length) this.setData(patch)
},
onClosePromoPopup() {
this.setData({ showPromoPopup: false })
},
onPopupAction() {
this.setData({ showPromoPopup: false })
recordConversion('homepage_popup')
wx.navigateTo({ url: '/pages/lottery/list/index' })
},
onShow() {
syncTabBarSelected(this, TAB_INDEX.home)
// 助力任务承载:从助力面板跳来时按任务条件计时上报。
+10
View File
@@ -212,3 +212,13 @@
<!-- 开屏广告:冷启动展示一次(V11.0 §5.1:优先微信原生,自建CPC兜底) -->
<splash-ad show="{{splashShow}}" duration="{{2}}" native-ad-unit-id="{{splashNativeAdUnitId}}" bind:close="onSplashClose" />
<!-- A/B 实验:首页弹窗文案(homepage_popup) -->
<view wx:if="{{showPromoPopup}}" class="promo-popup-mask" bindtap="onClosePromoPopup">
<view class="promo-popup-card" catchtap="">
<view class="promo-popup-close" bindtap="onClosePromoPopup">×</view>
<text class="promo-popup-title">{{popupTitle}}</text>
<text class="promo-popup-desc">{{popupDescription}}</text>
<button class="promo-popup-btn btn btn-primary" bindtap="onPopupAction">{{popupButtonText}}</button>
</view>
</view>
+26
View File
@@ -528,3 +528,29 @@
color: #bbb;
margin-top: 4rpx;
}
/* A/B 实验:首页弹窗 */
.promo-popup-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 1000;
display: flex; align-items: center; justify-content: center;
}
.promo-popup-card {
width: 580rpx; background: #fff; border-radius: 24rpx;
padding: 60rpx 40rpx 40rpx; text-align: center; position: relative;
}
.promo-popup-close {
position: absolute; top: 16rpx; right: 24rpx;
font-size: 48rpx; color: #999; line-height: 1;
}
.promo-popup-title {
display: block; font-size: 40rpx; font-weight: 700; color: #333;
margin-bottom: 16rpx;
}
.promo-popup-desc {
display: block; font-size: 28rpx; color: #666;
margin-bottom: 40rpx; line-height: 1.5;
}
.promo-popup-btn {
width: 80% !important; margin: 0 auto;
}
+16
View File
@@ -3,6 +3,7 @@ import { addressApi } from '../../../utils/address-api'
import { pointsApi } from '../../../utils/points-api'
import { requestPayment } from '../../../utils/pay'
import { formatTime } from '../../../utils/format'
import { abAssign } from '../../../utils/ab-api'
// 常见富文本标签命中即视为已是 HTML 内容
const DETAIL_HTML_TAG_RE = /<(?:p|br|div|span|img|h[1-6]|ul|ol|li|strong|em|b|i|u|s|blockquote|table|thead|tbody|tr|td|th|a|section|figure|figcaption|hr|code|pre)\b/i
@@ -55,6 +56,10 @@ Page({
showOrderSheet: false,
// 防止规格选择器关闭时误触订单弹层mask
blockOrderSheetClose: false,
// A/B 实验:商品详情页按钮文案(product_button)
buyButtonText: '立即购买',
exchangeButtonText: '积分兑换',
urgencyText: '',
},
onLoad(options: any) {
@@ -63,6 +68,7 @@ Page({
this.loadProduct(id)
this.loadAddresses()
this.loadMyPoints()
this.loadAbConfig()
}
},
@@ -71,6 +77,16 @@ Page({
if (this.data.product) this.loadAddresses()
},
async loadAbConfig() {
const res = await abAssign('product_button')
const p = (res && res.assigned && res.payload) || {}
const patch: Record<string, any> = {}
if (typeof p.buy_button_text === 'string' && p.buy_button_text) patch.buyButtonText = p.buy_button_text.slice(0, 8)
if (typeof p.exchange_button_text === 'string' && p.exchange_button_text) patch.exchangeButtonText = p.exchange_button_text.slice(0, 8)
if (typeof p.urgency_text === 'string') patch.urgencyText = p.urgency_text.slice(0, 20)
if (Object.keys(patch).length) this.setData(patch)
},
async loadProduct(id: number) {
try {
const p = await mallApi.getProductDetail(id)
+2 -1
View File
@@ -89,10 +89,11 @@
<button class="btn btn-primary btn-small"
disabled="{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) || ordering || (payMode === 'points' && product.points_cost > myPoints)}}"
bindtap="openOrderSheet">
{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) ? '已售罄' : (payMode === 'points' ? '积分兑换' : '立即购买')}}
{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) ? '已售罄' : (payMode === 'points' ? exchangeButtonText : buyButtonText)}}
</button>
</view>
</view>
<view wx:if="{{urgencyText}}" class="urgency-tip">{{urgencyText}}</view>
<!-- 购买确认弹层(优惠券/地址/备注在此填写) -->
<view wx:if="{{showOrderSheet}}" class="picker-mask" bindtap="closeOrderSheet">
+2
View File
@@ -104,3 +104,5 @@
.review-reply-content { color: var(--text); }
.review-empty { padding-top: var(--space-sm); text-align: center; }
.review-empty-text { font-size: var(--text-sm); color: var(--text-secondary); }
.urgency-tip { text-align: center; font-size: 24rpx; color: #ff4d4f; margin-top: 8rpx; font-weight: 500; }
@@ -3,6 +3,7 @@ import { prepareShareToken, buildSharePath } from '../../../utils/share'
import { reportShareVisit } from '../../../utils/visit'
import { formatDrawTime } from '../../../utils/format'
import { joinErrorMessage, JOIN_CODES } from '../../../utils/error-map'
import { abAssign } from '../../../utils/ab-api'
interface DayItem {
day: number
@@ -77,6 +78,10 @@ Page({
progressPercent: 0,
navHeight: 0,
shareToken: '',
// A/B 实验:签到页奖励展示(signin_reward)
rewardTitle: '签到领积分',
streakDescription: '连续签到奖励更多',
showStreakBonus: true,
},
async prepareShare(id: number) {
@@ -100,6 +105,8 @@ Page({
} else {
this.loadAll(id)
}
this.loadAbConfig()
},
onShow() {
@@ -107,6 +114,16 @@ Page({
if (this.data.lottery) this.loadAll(this.data.id)
},
async loadAbConfig() {
const res = await abAssign('signin_reward')
const p = (res && res.assigned && res.payload) || {}
const patch: Record<string, any> = {}
if (typeof p.reward_title === 'string' && p.reward_title) patch.rewardTitle = p.reward_title.slice(0, 10)
if (typeof p.streak_description === 'string' && p.streak_description) patch.streakDescription = p.streak_description.slice(0, 20)
if (typeof p.show_streak_bonus === 'boolean') patch.showStreakBonus = p.show_streak_bonus
if (Object.keys(patch).length) this.setData(patch)
},
async loadAll(id: number) {
this.setData({ loading: true })
try {
@@ -40,7 +40,7 @@
<!-- 主区:签到抽奖 -->
<view class="section card">
<view class="section-title">签到抽奖</view>
<view class="section-title">{{rewardTitle}}</view>
<view class="section-desc">截至开奖前,签到任意 {{lottery.threshold_days}} 天即可参与抽奖,每日首次签到还有额外奖励</view>
<!-- 7 天格子(3 列 x 2 行) + 第 7 天大格子 -->
@@ -74,6 +74,7 @@
</view>
</view>
<view wx:if="{{showStreakBonus}}" class="streak-tip">{{streakDescription}}</view>
<button
class="checkin-btn btn btn-primary {{canCheckin && !checkingIn ? '' : 'btn-disabled'}}"
bindtap="onCheckin"
@@ -67,3 +67,5 @@
.empty-state .empty-icon { font-size: 100rpx; opacity: 0.6; }
.empty-state .empty-text { font-size: var(--text-lg); color: var(--text-secondary); margin-top: var(--space-md); }
.empty-state .empty-sub { font-size: var(--text-base); color: var(--text-disabled); margin-top: var(--space-xs); }
.streak-tip { text-align: center; font-size: 24rpx; color: #ff5e8a; margin-bottom: var(--space-sm); font-weight: 500; }
+6
View File
@@ -22,3 +22,9 @@ export async function abAssign(variable: string): Promise<ABAssignResult> {
return { assigned: false }
}
}
/** 上报前端触发型转化事件(如弹窗按钮点击)。
* 事件名由后端注册表决定,客户端仅传 variable。失败静默,不阻断主流程。 */
export function recordConversion(variable: string) {
api.post('/mp/ab/event', { variable }).catch(() => {})
}