增加幸运礼物 AB 实验:按用户哈希分流灰度发布配置版本
- lucky_gift_experiments/overrides 表(006 迁移,含分组统计索引与 binary user_key) - 创建实验=同事务发布 treatment+登记;两组强制同策略;实验期常规发布拒绝 - Check/单抽/批量/外部抽按用户钉住组内版本;结束实验重发布获胜快照全量收敛 - AdminLuckyGiftService 新增 5 个实验 RPC(放量字段 proto3 optional) - admin 实验管理 HTTP API + lucky-gift:experiment 权限(迁移 103) - 设计文档 docs/幸运礼物AB实验.md;域/真库/admin 回归测试 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9ee45a3a52
commit
dc8435f869
File diff suppressed because it is too large
Load Diff
@ -299,6 +299,120 @@ message ListLuckyGiftPoolBalancesResponse {
|
||||
repeated LuckyGiftPoolBalance pools = 1;
|
||||
}
|
||||
|
||||
// LuckyGiftExperimentOverride 把单个用户强制固定到某个实验组,用于放量前的白名单验证。
|
||||
// user_key:内部用户为十进制 user_id 字符串;外部用户为 "ext:" + external_user_id。
|
||||
message LuckyGiftExperimentOverride {
|
||||
string user_key = 1;
|
||||
string arm = 2;
|
||||
}
|
||||
|
||||
// LuckyGiftExperimentArmStats 是实验开始以来单组的抽奖聚合,由 lucky_draw_records 按 rule_version 现算,非物化口径。
|
||||
message LuckyGiftExperimentArmStats {
|
||||
string arm = 1;
|
||||
int64 rule_version = 2;
|
||||
string strategy_version = 3;
|
||||
int64 total_draws = 4;
|
||||
int64 unique_users = 5;
|
||||
int64 total_spent_coins = 6;
|
||||
int64 total_reward_coins = 7;
|
||||
int64 actual_rtp_ppm = 8;
|
||||
}
|
||||
|
||||
// LuckyGiftExperiment 是单奖池 AB 实验事实:两组各钉住一个不可变 rule_version,
|
||||
// 运行时按 bucket_salt+user 哈希分流;同一奖池同时最多一个未完成实验。
|
||||
message LuckyGiftExperiment {
|
||||
string app_code = 1;
|
||||
string pool_id = 2;
|
||||
string experiment_id = 3;
|
||||
string name = 4;
|
||||
int64 control_rule_version = 5;
|
||||
int64 treatment_rule_version = 6;
|
||||
// treatment 组流量比例(ppm,0..1000000);0 表示只有白名单 override 能进入 treatment。
|
||||
int64 treatment_traffic_ppm = 7;
|
||||
string bucket_salt = 8;
|
||||
// running / paused / completed;paused 时全部流量(含白名单)回落 control。
|
||||
string status = 9;
|
||||
string winner_arm = 10;
|
||||
int64 winner_rule_version = 11;
|
||||
// 结束实验时把获胜配置重发布成的新最新版本,全量流量由它承接。
|
||||
int64 final_rule_version = 12;
|
||||
int64 created_by_admin_id = 13;
|
||||
int64 started_at_ms = 14;
|
||||
int64 ended_at_ms = 15;
|
||||
int64 updated_at_ms = 16;
|
||||
repeated LuckyGiftExperimentOverride overrides = 17;
|
||||
repeated LuckyGiftExperimentArmStats arm_stats = 18;
|
||||
}
|
||||
|
||||
// CreateLuckyGiftExperimentRequest 在一个事务里发布 treatment 新版本并登记实验,
|
||||
// 避免"先发布后建实验"窗口期内新版本被最新版本语义全量放开。
|
||||
// control 组自动钉住当前最新已启用版本。
|
||||
message CreateLuckyGiftExperimentRequest {
|
||||
RequestMeta meta = 1;
|
||||
string name = 2;
|
||||
LuckyGiftRuleConfig treatment_config = 3;
|
||||
int64 treatment_traffic_ppm = 4;
|
||||
int64 operator_admin_id = 5;
|
||||
repeated LuckyGiftExperimentOverride overrides = 6;
|
||||
}
|
||||
|
||||
message CreateLuckyGiftExperimentResponse {
|
||||
LuckyGiftExperiment experiment = 1;
|
||||
LuckyGiftRuleConfig treatment_config = 2;
|
||||
}
|
||||
|
||||
message ListLuckyGiftExperimentsRequest {
|
||||
RequestMeta meta = 1;
|
||||
string pool_id = 2;
|
||||
string status = 3;
|
||||
bool with_stats = 4;
|
||||
}
|
||||
|
||||
message ListLuckyGiftExperimentsResponse {
|
||||
repeated LuckyGiftExperiment experiments = 1;
|
||||
}
|
||||
|
||||
// UpdateLuckyGiftExperimentRequest 调整放量或暂停/恢复。
|
||||
// treatment_traffic_ppm 使用显式存在性语义:未设置表示不修改流量,避免 proto3 缺省 0 被误解释成"放量清零";
|
||||
// status 为空表示不修改状态。
|
||||
message UpdateLuckyGiftExperimentRequest {
|
||||
RequestMeta meta = 1;
|
||||
string experiment_id = 2;
|
||||
optional int64 treatment_traffic_ppm = 3;
|
||||
string status = 4;
|
||||
int64 operator_admin_id = 5;
|
||||
}
|
||||
|
||||
message UpdateLuckyGiftExperimentResponse {
|
||||
LuckyGiftExperiment experiment = 1;
|
||||
}
|
||||
|
||||
message SetLuckyGiftExperimentOverridesRequest {
|
||||
RequestMeta meta = 1;
|
||||
string experiment_id = 2;
|
||||
repeated LuckyGiftExperimentOverride upserts = 3;
|
||||
repeated string removes = 4;
|
||||
int64 operator_admin_id = 5;
|
||||
}
|
||||
|
||||
message SetLuckyGiftExperimentOverridesResponse {
|
||||
LuckyGiftExperiment experiment = 1;
|
||||
}
|
||||
|
||||
// EndLuckyGiftExperimentRequest 结束实验:把 winner_arm 钉住的配置快照重发布为新的最新版本,
|
||||
// 同事务内完成实验收口,保证结束瞬间的版本收敛没有中间态。
|
||||
message EndLuckyGiftExperimentRequest {
|
||||
RequestMeta meta = 1;
|
||||
string experiment_id = 2;
|
||||
string winner_arm = 3;
|
||||
int64 operator_admin_id = 4;
|
||||
}
|
||||
|
||||
message EndLuckyGiftExperimentResponse {
|
||||
LuckyGiftExperiment experiment = 1;
|
||||
LuckyGiftRuleConfig final_config = 2;
|
||||
}
|
||||
|
||||
service LuckyGiftService {
|
||||
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
|
||||
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
|
||||
@ -314,6 +428,11 @@ service AdminLuckyGiftService {
|
||||
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
|
||||
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
|
||||
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
|
||||
rpc CreateLuckyGiftExperiment(CreateLuckyGiftExperimentRequest) returns (CreateLuckyGiftExperimentResponse);
|
||||
rpc ListLuckyGiftExperiments(ListLuckyGiftExperimentsRequest) returns (ListLuckyGiftExperimentsResponse);
|
||||
rpc UpdateLuckyGiftExperiment(UpdateLuckyGiftExperimentRequest) returns (UpdateLuckyGiftExperimentResponse);
|
||||
rpc SetLuckyGiftExperimentOverrides(SetLuckyGiftExperimentOverridesRequest) returns (SetLuckyGiftExperimentOverridesResponse);
|
||||
rpc EndLuckyGiftExperiment(EndLuckyGiftExperimentRequest) returns (EndLuckyGiftExperimentResponse);
|
||||
}
|
||||
|
||||
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.29.2
|
||||
// - protoc v7.35.0
|
||||
// source: proto/luckygift/v1/luckygift.proto
|
||||
|
||||
package luckygiftv1
|
||||
@ -235,13 +235,18 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
|
||||
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
|
||||
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
|
||||
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
||||
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
||||
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
||||
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
|
||||
AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig"
|
||||
AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig"
|
||||
AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs"
|
||||
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
|
||||
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
|
||||
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
|
||||
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
|
||||
AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/CreateLuckyGiftExperiment"
|
||||
AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftExperiments"
|
||||
AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpdateLuckyGiftExperiment"
|
||||
AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/SetLuckyGiftExperimentOverrides"
|
||||
AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/EndLuckyGiftExperiment"
|
||||
)
|
||||
|
||||
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service.
|
||||
@ -255,6 +260,11 @@ type AdminLuckyGiftServiceClient interface {
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||
AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
||||
CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error)
|
||||
ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error)
|
||||
UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error)
|
||||
SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error)
|
||||
EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error)
|
||||
}
|
||||
|
||||
type adminLuckyGiftServiceClient struct {
|
||||
@ -335,6 +345,56 @@ func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Con
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) CreateLuckyGiftExperiment(ctx context.Context, in *CreateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*CreateLuckyGiftExperimentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateLuckyGiftExperimentResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) ListLuckyGiftExperiments(ctx context.Context, in *ListLuckyGiftExperimentsRequest, opts ...grpc.CallOption) (*ListLuckyGiftExperimentsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListLuckyGiftExperimentsResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) UpdateLuckyGiftExperiment(ctx context.Context, in *UpdateLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*UpdateLuckyGiftExperimentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateLuckyGiftExperimentResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) SetLuckyGiftExperimentOverrides(ctx context.Context, in *SetLuckyGiftExperimentOverridesRequest, opts ...grpc.CallOption) (*SetLuckyGiftExperimentOverridesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetLuckyGiftExperimentOverridesResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminLuckyGiftServiceClient) EndLuckyGiftExperiment(ctx context.Context, in *EndLuckyGiftExperimentRequest, opts ...grpc.CallOption) (*EndLuckyGiftExperimentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(EndLuckyGiftExperimentResponse)
|
||||
err := c.cc.Invoke(ctx, AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
|
||||
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer
|
||||
// for forward compatibility.
|
||||
@ -346,6 +406,11 @@ type AdminLuckyGiftServiceServer interface {
|
||||
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
|
||||
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
|
||||
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
|
||||
CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error)
|
||||
ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error)
|
||||
UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error)
|
||||
SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error)
|
||||
EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error)
|
||||
mustEmbedUnimplementedAdminLuckyGiftServiceServer()
|
||||
}
|
||||
|
||||
@ -377,6 +442,21 @@ func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(contex
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) CreateLuckyGiftExperiment(context.Context, *CreateLuckyGiftExperimentRequest) (*CreateLuckyGiftExperimentResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CreateLuckyGiftExperiment not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftExperiments(context.Context, *ListLuckyGiftExperimentsRequest) (*ListLuckyGiftExperimentsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftExperiments not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) UpdateLuckyGiftExperiment(context.Context, *UpdateLuckyGiftExperimentRequest) (*UpdateLuckyGiftExperimentResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateLuckyGiftExperiment not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) SetLuckyGiftExperimentOverrides(context.Context, *SetLuckyGiftExperimentOverridesRequest) (*SetLuckyGiftExperimentOverridesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetLuckyGiftExperimentOverrides not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) EndLuckyGiftExperiment(context.Context, *EndLuckyGiftExperimentRequest) (*EndLuckyGiftExperimentResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method EndLuckyGiftExperiment not implemented")
|
||||
}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
|
||||
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -524,6 +604,96 @@ func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{},
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateLuckyGiftExperimentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_CreateLuckyGiftExperiment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).CreateLuckyGiftExperiment(ctx, req.(*CreateLuckyGiftExperimentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListLuckyGiftExperimentsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_ListLuckyGiftExperiments_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).ListLuckyGiftExperiments(ctx, req.(*ListLuckyGiftExperimentsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateLuckyGiftExperimentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_UpdateLuckyGiftExperiment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).UpdateLuckyGiftExperiment(ctx, req.(*UpdateLuckyGiftExperimentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetLuckyGiftExperimentOverridesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).SetLuckyGiftExperimentOverrides(ctx, req.(*SetLuckyGiftExperimentOverridesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EndLuckyGiftExperimentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminLuckyGiftService_EndLuckyGiftExperiment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminLuckyGiftServiceServer).EndLuckyGiftExperiment(ctx, req.(*EndLuckyGiftExperimentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -559,6 +729,26 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "AdjustLuckyGiftPoolBalance",
|
||||
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateLuckyGiftExperiment",
|
||||
Handler: _AdminLuckyGiftService_CreateLuckyGiftExperiment_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListLuckyGiftExperiments",
|
||||
Handler: _AdminLuckyGiftService_ListLuckyGiftExperiments_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateLuckyGiftExperiment",
|
||||
Handler: _AdminLuckyGiftService_UpdateLuckyGiftExperiment_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetLuckyGiftExperimentOverrides",
|
||||
Handler: _AdminLuckyGiftService_SetLuckyGiftExperimentOverrides_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "EndLuckyGiftExperiment",
|
||||
Handler: _AdminLuckyGiftService_EndLuckyGiftExperiment_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/luckygift/v1/luckygift.proto",
|
||||
|
||||
121
docs/幸运礼物AB实验.md
Normal file
121
docs/幸运礼物AB实验.md
Normal file
@ -0,0 +1,121 @@
|
||||
# 幸运礼物 AB 实验(灰度放量)
|
||||
|
||||
> 2026-07 引入。允许对同一 app + 奖池发布一个"实验组"配置版本,只对按用户哈希圈定的部分用户生效,
|
||||
> 其余用户继续使用原版本;支持白名单验证、放量调整、暂停与选择获胜方收口。
|
||||
|
||||
## 一、动机与总体设计
|
||||
|
||||
幸运礼物配置是不可变版本流(`lucky_gift_rule_versions`,PK = app_code + pool_id + rule_version):
|
||||
发布 = 追加 `max(rule_version)+1` 的新行,运行时永远取最新版本。因此**发布即全量**,没有灰度空间。
|
||||
|
||||
AB 实验在其上加一层"版本钉住"语义,不改变版本流本身:
|
||||
|
||||
- 实验(`lucky_gift_experiments`)为一个奖池钉住两个已存在的不可变版本:
|
||||
`control_rule_version`(对照组 = 创建实验时的最新启用版本)与 `treatment_rule_version`(实验组 = 随实验**同事务原子发布**的新版本)。
|
||||
- 有未完成实验时,抽奖/预检查按用户解析组并加载对应版本;无实验时保持"最新版本生效"原语义。
|
||||
- 同一奖池同时最多一个未完成实验(`active` 生成列 + 唯一键兜底)。
|
||||
|
||||
### 为什么"创建实验 = 原子发布 + 登记"
|
||||
|
||||
若先正常发布 v10 再建实验,发布提交到实验生效之间存在窗口:此时 v10 已是最新版本,**全量用户瞬间切到 v10**。
|
||||
因此 `CreateLuckyGiftExperiment` 在一个 MySQL 事务里完成 treatment 发布与实验登记(`lucky_gift_experiment_repository.go`)。
|
||||
同理,实验进行中常规发布被拒绝(409):实验期两组各自钉住版本,新发布的"最新版本"不会生效,只会造成困惑。
|
||||
|
||||
### 分流算法
|
||||
|
||||
`FNV-1a 64(salt ":" userKey) % 1,000,000 < treatment_traffic_ppm → treatment`(`internal/domain/luckygift/experiment.go`)。
|
||||
|
||||
- userKey:内部用户为十进制 user_id;外部 App 用户为 `ext:` + external_user_id。两条抽奖链路共用同一分流。
|
||||
白名单 user_key 列使用 utf8mb4_bin(字节精确),与 FNV 分桶口径一致。
|
||||
- **确定性**:实验状态不变时,同一用户在 Check 与 Draw 解析到同一组(room-service 送礼 saga 在 CheckLuckyGift
|
||||
固化 strategy_version、恢复阶段不重新 Check)。放量/白名单/暂停操作会让分组在两次解析间漂移,因此——
|
||||
- **两组必须同策略族**(创建实验强制校验):同策略下分组漂移只是参数差异,不会击穿 room saga 对
|
||||
device_id/paid_at 的扣费前拦截。fixed_v2 → dynamic_v3 的**策略迁移走常规发布**,不走实验
|
||||
(旧客户端本就无法进入 dynamic 组,混合策略实验的分组天然有偏差,统计上也不成立)。
|
||||
- **单调放量**:`bucket < traffic` 的比较方式保证调大流量时已在实验组的用户全部保留,只有新用户迁入;
|
||||
调小流量会把部分用户迁回对照组(用户状态跨版本共享,见下)。
|
||||
- 白名单(`lucky_gift_experiment_overrides`)优先于哈希,可强制任一组,用于放量前的真机验证(流量 0% + 白名单);
|
||||
单实验累计上限 500。
|
||||
- `paused` 是紧急停闸:全部流量(**含白名单**)回落对照组;恢复 running 后原分组不变。
|
||||
|
||||
### 与既有机制的关系(为什么这套结构对实验天然安全)
|
||||
|
||||
| 机制 | 维度 | 实验语义 |
|
||||
|---|---|---|
|
||||
| RTP 窗口 `lucky_rtp_windows` | `pool:v{rule_version}` | 天然按组隔离,两组 RTP 观察互不污染 |
|
||||
| 资金池 `lucky_pools` | fixed_v2: `pool`+poolID;dynamic_v3: `pool_dynamic_v3`+poolID | 两组同策略(强制),共享同一口支付能力,按组核算走抽奖事实 |
|
||||
| 用户状态 `lucky_user_states`、日/72h RTP、六维风控 | 按用户/设备/房间,不含版本 | 跨组共享;用户粘在一组内,状态连续,不因实验重置保底/风控额度 |
|
||||
| 抽奖事实 `lucky_draw_records` | 每抽落 `rule_version` | 分组统计免费:按 rule_version 聚合即得两组口径 |
|
||||
|
||||
### 结束实验(收口)
|
||||
|
||||
`EndLuckyGiftExperiment(winner_arm)` 在一个事务里:把获胜组钉住的配置**快照重发布**为新的最新版本
|
||||
(`final_rule_version`),并把实验标记 completed。结束瞬间"最新版本生效"的默认语义恰好收敛到获胜配置,无中间态:
|
||||
|
||||
- treatment 胜 → 全量放开新配置(等效全量上线);
|
||||
- control 胜 → 回滚,等效撤销本次变更(treatment 版本留在版本流里成为历史)。
|
||||
|
||||
注意:若 treatment 是 dynamic_v3 而 control 胜出,实验期物化的 dynamic 账本及其人工注资仍在,需要用现有"扣减水位"人工调账收回。
|
||||
|
||||
## 二、数据模型
|
||||
|
||||
`services/lucky-gift-service/deploy/mysql/migrations/006_ab_experiments.sql`(initdb 001 已同步):
|
||||
|
||||
- `lucky_gift_experiments`:PK (app_code, experiment_id);`active TINYINT AS (IF(status IN ('running','paused'),1,NULL)) STORED`,
|
||||
`UNIQUE (app_code, pool_id, active)` ⇒ 每池至多一个未完成实验,completed 历史不限量。
|
||||
- `lucky_gift_experiment_overrides`:PK (app_code, experiment_id, user_key),arm ∈ {control, treatment}。白名单上限 500(service 校验)。
|
||||
|
||||
## 三、运行时接入点(lucky-gift-service)
|
||||
|
||||
`resolveLuckyGiftRuleConfigForUser`(`lucky_gift_experiment_repository.go`)替换了四处配置加载:
|
||||
|
||||
1. `CheckLuckyGift`(送礼预检查,非锁读)
|
||||
2. `executeSingleLuckyGiftDraw`(单抽,事务内 FOR UPDATE)
|
||||
3. `executeOptimizedLuckyGiftDrawBatch`(批量抽,事务内 FOR UPDATE)
|
||||
4. `ExecuteExternalGiftDraw`(外部 App,ext: 前缀 userKey)
|
||||
|
||||
实验行用一致性快照读(不加锁),钉住的版本行按原语义加锁;两组锁不同版本行但仍在共享资金池行上串行,
|
||||
锁序(规则行 → 实验行)在常规发布、创建实验、结束实验三个入口保持一致,无交叉死锁。
|
||||
钉住版本行缺失 = 不可变事实被外部破坏,fail-close 报错,不静默回落最新版本。
|
||||
|
||||
已知取舍(均无资金风险):
|
||||
- 创建实验提交瞬间,个别在途抽奖事务可能按"最新版本=treatment"开奖一次(毫秒级窗口,两组配置都已校验可运行)。
|
||||
- 不同奖池并发创建实验可能在唯一索引间隙锁上触发一次 InnoDB 死锁回滚——管理端低频操作,报错重试即可。
|
||||
- 结束实验以获胜方快照重发布时,dynamic_v3 遗留的 initial_pool_coins 会被清零(与运行时读取口径一致)。
|
||||
|
||||
## 四、管理 API
|
||||
|
||||
gRPC `luckygift.v1.AdminLuckyGiftService` 新增:CreateLuckyGiftExperiment / ListLuckyGiftExperiments(with_stats 返回分组聚合)/
|
||||
UpdateLuckyGiftExperiment(放量、暂停恢复)/ SetLuckyGiftExperimentOverrides / EndLuckyGiftExperiment。
|
||||
|
||||
admin HTTP(前缀 /api/v1/admin/ops-center,权限:查看 `lucky-gift:view`、管理 `lucky-gift:experiment`):
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| GET | /lucky-gifts/experiments?app_code&pool_id&status&with_stats | 实验列表(含分组统计) |
|
||||
| POST | /lucky-gifts/experiments?app_code | 创建(body: name + treatment_traffic_percent + overrides + config) |
|
||||
| PATCH | /lucky-gifts/experiments/{id}?app_code | 放量 / 暂停 / 恢复 |
|
||||
| PUT | /lucky-gifts/experiments/{id}/overrides?app_code | 白名单增删 |
|
||||
| POST | /lucky-gifts/experiments/{id}/end?app_code | 结束并选择获胜方 |
|
||||
|
||||
ops-center 前端在幸运礼物配置页集成:实验中奖池带徽章与"实验详情",行操作可"创建 AB 实验"(复用配置表单 + 实验字段),
|
||||
管理弹窗支持放量滑调、白名单、分组指标对比与结束实验二次确认。
|
||||
|
||||
## 五、标准操作流程(以 lalu super_lucky 升级为例)
|
||||
|
||||
1. 配置页 super_lucky 行 → "创建 AB 实验":填新配置内容、实验名,流量先设 0%,白名单加测试账号(arm=treatment)。
|
||||
2. 测试账号真机验证:送幸运礼物应命中新版本(抽奖记录 rule_version = treatment 版本)。
|
||||
3. 实验详情 → 放量 5% → 观察两组 RTP/消耗/独立用户(结算口径差异需累计足够流水才有意义)→ 20% → 50%。
|
||||
4. 异常随时"暂停"(全量回对照组,不销毁分组);修复后恢复。
|
||||
5. 数据达标 → "结束实验",选获胜方;系统重发布获胜配置为新版本并全量收敛。
|
||||
|
||||
## 六、发布与排障
|
||||
|
||||
- **迁移**:生产按既有流程手动执行 `006_ab_experiments.sql`(`mysql_auto_migrate` 不建表);本地 docker 新库由 initdb 覆盖,
|
||||
旧库需手动执行 006。测试环境同理。
|
||||
- 分组核对 SQL:
|
||||
`SELECT rule_version, COUNT(*), SUM(coin_spent), SUM(effective_reward_coins) FROM lucky_draw_records
|
||||
WHERE app_code=? AND pool_id=? AND created_at_ms>=? GROUP BY rule_version;`
|
||||
- 某用户实际在哪组:查 `lucky_gift_experiment_overrides`;无 override 时按 salt 哈希(域函数 `ExperimentBucketPPM`,
|
||||
可用 strategy-sim 或临时 Go 片段复算)。
|
||||
- 真库回归:`LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN=... go test ./services/lucky-gift-service/internal/storage/mysql/ -run Experiment`。
|
||||
@ -18,6 +18,11 @@ type Client interface {
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
|
||||
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
|
||||
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
|
||||
CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error)
|
||||
ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error)
|
||||
UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error)
|
||||
SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error)
|
||||
EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error)
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
@ -55,3 +60,23 @@ func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygi
|
||||
func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) {
|
||||
return c.client.AdjustLuckyGiftPoolBalance(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
||||
return c.client.CreateLuckyGiftExperiment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) {
|
||||
return c.client.ListLuckyGiftExperiments(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
||||
return c.client.UpdateLuckyGiftExperiment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
|
||||
return c.client.SetLuckyGiftExperimentOverrides(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
|
||||
return c.client.EndLuckyGiftExperiment(ctx, req)
|
||||
}
|
||||
|
||||
312
server/admin/internal/modules/luckygift/experiments.go
Normal file
312
server/admin/internal/modules/luckygift/experiments.go
Normal file
@ -0,0 +1,312 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type experimentOverrideDTO struct {
|
||||
UserKey string `json:"user_key"`
|
||||
Arm string `json:"arm"`
|
||||
}
|
||||
|
||||
type experimentArmStatsDTO struct {
|
||||
Arm string `json:"arm"`
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
TotalDraws int64 `json:"total_draws"`
|
||||
UniqueUsers int64 `json:"unique_users"`
|
||||
TotalSpentCoins int64 `json:"total_spent_coins"`
|
||||
TotalRewardCoins int64 `json:"total_reward_coins"`
|
||||
ActualRTPPercent float64 `json:"actual_rtp_percent"`
|
||||
}
|
||||
|
||||
type experimentDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
ExperimentID string `json:"experiment_id"`
|
||||
Name string `json:"name"`
|
||||
ControlRuleVersion int64 `json:"control_rule_version"`
|
||||
TreatmentRuleVersion int64 `json:"treatment_rule_version"`
|
||||
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
|
||||
Status string `json:"status"`
|
||||
WinnerArm string `json:"winner_arm"`
|
||||
WinnerRuleVersion int64 `json:"winner_rule_version"`
|
||||
FinalRuleVersion int64 `json:"final_rule_version"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
StartedAtMS int64 `json:"started_at_ms"`
|
||||
EndedAtMS int64 `json:"ended_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Overrides []experimentOverrideDTO `json:"overrides"`
|
||||
ArmStats []experimentArmStatsDTO `json:"arm_stats"`
|
||||
}
|
||||
|
||||
type createExperimentRequest struct {
|
||||
Name string `json:"name"`
|
||||
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
|
||||
Overrides []experimentOverrideDTO `json:"overrides"`
|
||||
Config configRequest `json:"config"`
|
||||
}
|
||||
|
||||
type updateExperimentRequest struct {
|
||||
// 指针区分「没传」与「显式传 0」:缺省时向 owner 传 -1 表示保持现有放量。
|
||||
TreatmentTrafficPercent *float64 `json:"treatment_traffic_percent"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type setExperimentOverridesRequest struct {
|
||||
Upserts []experimentOverrideDTO `json:"upserts"`
|
||||
Removes []string `json:"removes"`
|
||||
}
|
||||
|
||||
type endExperimentRequest struct {
|
||||
WinnerArm string `json:"winner_arm"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListLuckyGiftExperiments(c *gin.Context) {
|
||||
if strings.TrimSpace(c.Query("app_code")) == "" {
|
||||
response.BadRequest(c, "app_code is required")
|
||||
return
|
||||
}
|
||||
ctx, cancel := h.luckyGiftContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.luckyGift.ListLuckyGiftExperiments(ctx, &luckygiftv1.ListLuckyGiftExperimentsRequest{
|
||||
Meta: h.meta(c),
|
||||
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||
Status: strings.ToLower(strings.TrimSpace(c.Query("status"))),
|
||||
WithStats: parseOptionalBool(c.Query("with_stats")),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
items := make([]experimentDTO, 0, len(resp.GetExperiments()))
|
||||
for _, experiment := range resp.GetExperiments() {
|
||||
items = append(items, experimentFromProto(experiment))
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateLuckyGiftExperiment(c *gin.Context) {
|
||||
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
|
||||
if appCode == "" {
|
||||
response.BadRequest(c, "app_code is required")
|
||||
return
|
||||
}
|
||||
var req createExperimentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "幸运礼物实验参数不正确")
|
||||
return
|
||||
}
|
||||
if req.Config.AppCode == "" {
|
||||
req.Config.AppCode = appCode
|
||||
}
|
||||
ctx, cancel := h.luckyGiftContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.luckyGift.CreateLuckyGiftExperiment(ctx, &luckygiftv1.CreateLuckyGiftExperimentRequest{
|
||||
Meta: h.metaForApp(c, appCode),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
// treatment 配置复用 PUT /lucky-gifts/config 的解析归一逻辑(tier_id 生成、percent→ppm、
|
||||
// dynamic_v3 initial_pool_coins 清零),保证实验发布的新版本与常规发布口径一致。
|
||||
TreatmentConfig: configToProto(req.Config),
|
||||
TreatmentTrafficPpm: percentToPPM(req.TreatmentTrafficPercent),
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
Overrides: experimentOverridesToProto(req.Overrides),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
experiment := experimentFromProto(resp.GetExperiment())
|
||||
shared.OperationLogWithResourceID(
|
||||
c, h.audit, "create-lucky-gift-experiment", "lucky_gift_experiments", experiment.ExperimentID, "success",
|
||||
fmt.Sprintf("app_code=%s pool_id=%s treatment_traffic_ppm=%d", appCode, experiment.PoolID, percentToPPM(req.TreatmentTrafficPercent)),
|
||||
)
|
||||
response.OK(c, gin.H{
|
||||
"experiment": experiment,
|
||||
"treatment_config": configFromProto(resp.GetTreatmentConfig()),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateLuckyGiftExperiment(c *gin.Context) {
|
||||
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
|
||||
if appCode == "" {
|
||||
response.BadRequest(c, "app_code is required")
|
||||
return
|
||||
}
|
||||
experimentID := strings.TrimSpace(c.Param("experimentId"))
|
||||
if experimentID == "" {
|
||||
response.BadRequest(c, "experiment_id is required")
|
||||
return
|
||||
}
|
||||
var req updateExperimentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "幸运礼物实验参数不正确")
|
||||
return
|
||||
}
|
||||
trafficPPM := int64(-1)
|
||||
grpcReq := &luckygiftv1.UpdateLuckyGiftExperimentRequest{
|
||||
Meta: h.metaForApp(c, appCode),
|
||||
ExperimentId: experimentID,
|
||||
Status: strings.ToLower(strings.TrimSpace(req.Status)),
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
}
|
||||
if req.TreatmentTrafficPercent != nil {
|
||||
trafficPPM = percentToPPM(*req.TreatmentTrafficPercent)
|
||||
grpcReq.TreatmentTrafficPpm = &trafficPPM
|
||||
}
|
||||
ctx, cancel := h.luckyGiftContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.luckyGift.UpdateLuckyGiftExperiment(ctx, grpcReq)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(
|
||||
c, h.audit, "update-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
|
||||
fmt.Sprintf("app_code=%s treatment_traffic_ppm=%d status=%s", appCode, trafficPPM, strings.ToLower(strings.TrimSpace(req.Status))),
|
||||
)
|
||||
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
|
||||
}
|
||||
|
||||
func (h *Handler) SetLuckyGiftExperimentOverrides(c *gin.Context) {
|
||||
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
|
||||
if appCode == "" {
|
||||
response.BadRequest(c, "app_code is required")
|
||||
return
|
||||
}
|
||||
experimentID := strings.TrimSpace(c.Param("experimentId"))
|
||||
if experimentID == "" {
|
||||
response.BadRequest(c, "experiment_id is required")
|
||||
return
|
||||
}
|
||||
var req setExperimentOverridesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "幸运礼物实验白名单参数不正确")
|
||||
return
|
||||
}
|
||||
removes := make([]string, 0, len(req.Removes))
|
||||
for _, userKey := range req.Removes {
|
||||
removes = append(removes, strings.TrimSpace(userKey))
|
||||
}
|
||||
ctx, cancel := h.luckyGiftContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.luckyGift.SetLuckyGiftExperimentOverrides(ctx, &luckygiftv1.SetLuckyGiftExperimentOverridesRequest{
|
||||
Meta: h.metaForApp(c, appCode),
|
||||
ExperimentId: experimentID,
|
||||
Upserts: experimentOverridesToProto(req.Upserts),
|
||||
Removes: removes,
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(
|
||||
c, h.audit, "set-lucky-gift-experiment-overrides", "lucky_gift_experiments", experimentID, "success",
|
||||
fmt.Sprintf("app_code=%s upserts=%d removes=%d", appCode, len(req.Upserts), len(removes)),
|
||||
)
|
||||
response.OK(c, gin.H{"experiment": experimentFromProto(resp.GetExperiment())})
|
||||
}
|
||||
|
||||
func (h *Handler) EndLuckyGiftExperiment(c *gin.Context) {
|
||||
appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code")))
|
||||
if appCode == "" {
|
||||
response.BadRequest(c, "app_code is required")
|
||||
return
|
||||
}
|
||||
experimentID := strings.TrimSpace(c.Param("experimentId"))
|
||||
if experimentID == "" {
|
||||
response.BadRequest(c, "experiment_id is required")
|
||||
return
|
||||
}
|
||||
var req endExperimentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "幸运礼物实验参数不正确")
|
||||
return
|
||||
}
|
||||
winnerArm := strings.ToLower(strings.TrimSpace(req.WinnerArm))
|
||||
ctx, cancel := h.luckyGiftContext(c)
|
||||
defer cancel()
|
||||
resp, err := h.luckyGift.EndLuckyGiftExperiment(ctx, &luckygiftv1.EndLuckyGiftExperimentRequest{
|
||||
Meta: h.metaForApp(c, appCode),
|
||||
ExperimentId: experimentID,
|
||||
WinnerArm: winnerArm,
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(
|
||||
c, h.audit, "end-lucky-gift-experiment", "lucky_gift_experiments", experimentID, "success",
|
||||
fmt.Sprintf("app_code=%s winner_arm=%s", appCode, winnerArm),
|
||||
)
|
||||
response.OK(c, gin.H{
|
||||
"experiment": experimentFromProto(resp.GetExperiment()),
|
||||
"final_config": configFromProto(resp.GetFinalConfig()),
|
||||
})
|
||||
}
|
||||
|
||||
func experimentOverridesToProto(overrides []experimentOverrideDTO) []*luckygiftv1.LuckyGiftExperimentOverride {
|
||||
out := make([]*luckygiftv1.LuckyGiftExperimentOverride, 0, len(overrides))
|
||||
for _, override := range overrides {
|
||||
out = append(out, &luckygiftv1.LuckyGiftExperimentOverride{
|
||||
UserKey: strings.TrimSpace(override.UserKey),
|
||||
Arm: strings.ToLower(strings.TrimSpace(override.Arm)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func experimentFromProto(experiment *luckygiftv1.LuckyGiftExperiment) experimentDTO {
|
||||
if experiment == nil {
|
||||
return experimentDTO{Overrides: []experimentOverrideDTO{}, ArmStats: []experimentArmStatsDTO{}}
|
||||
}
|
||||
overrides := make([]experimentOverrideDTO, 0, len(experiment.GetOverrides()))
|
||||
for _, override := range experiment.GetOverrides() {
|
||||
overrides = append(overrides, experimentOverrideDTO{
|
||||
UserKey: override.GetUserKey(),
|
||||
Arm: override.GetArm(),
|
||||
})
|
||||
}
|
||||
armStats := make([]experimentArmStatsDTO, 0, len(experiment.GetArmStats()))
|
||||
for _, stats := range experiment.GetArmStats() {
|
||||
armStats = append(armStats, experimentArmStatsDTO{
|
||||
Arm: stats.GetArm(),
|
||||
RuleVersion: stats.GetRuleVersion(),
|
||||
StrategyVersion: stats.GetStrategyVersion(),
|
||||
TotalDraws: stats.GetTotalDraws(),
|
||||
UniqueUsers: stats.GetUniqueUsers(),
|
||||
TotalSpentCoins: stats.GetTotalSpentCoins(),
|
||||
TotalRewardCoins: stats.GetTotalRewardCoins(),
|
||||
ActualRTPPercent: ppmToPercent(stats.GetActualRtpPpm()),
|
||||
})
|
||||
}
|
||||
return experimentDTO{
|
||||
AppCode: experiment.GetAppCode(),
|
||||
PoolID: experiment.GetPoolId(),
|
||||
ExperimentID: experiment.GetExperimentId(),
|
||||
Name: experiment.GetName(),
|
||||
ControlRuleVersion: experiment.GetControlRuleVersion(),
|
||||
TreatmentRuleVersion: experiment.GetTreatmentRuleVersion(),
|
||||
TreatmentTrafficPercent: ppmToPercent(experiment.GetTreatmentTrafficPpm()),
|
||||
Status: experiment.GetStatus(),
|
||||
WinnerArm: experiment.GetWinnerArm(),
|
||||
WinnerRuleVersion: experiment.GetWinnerRuleVersion(),
|
||||
FinalRuleVersion: experiment.GetFinalRuleVersion(),
|
||||
CreatedByAdminID: experiment.GetCreatedByAdminId(),
|
||||
StartedAtMS: experiment.GetStartedAtMs(),
|
||||
EndedAtMS: experiment.GetEndedAtMs(),
|
||||
UpdatedAtMS: experiment.GetUpdatedAtMs(),
|
||||
Overrides: overrides,
|
||||
ArmStats: armStats,
|
||||
}
|
||||
}
|
||||
277
server/admin/internal/modules/luckygift/experiments_test.go
Normal file
277
server/admin/internal/modules/luckygift/experiments_test.go
Normal file
@ -0,0 +1,277 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/integration/luckygiftadmin"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type experimentClientStub struct {
|
||||
luckygiftadmin.Client
|
||||
createRequest *luckygiftv1.CreateLuckyGiftExperimentRequest
|
||||
createResponse *luckygiftv1.CreateLuckyGiftExperimentResponse
|
||||
updateRequest *luckygiftv1.UpdateLuckyGiftExperimentRequest
|
||||
updateResponse *luckygiftv1.UpdateLuckyGiftExperimentResponse
|
||||
overridesRequest *luckygiftv1.SetLuckyGiftExperimentOverridesRequest
|
||||
endRequest *luckygiftv1.EndLuckyGiftExperimentRequest
|
||||
endResponse *luckygiftv1.EndLuckyGiftExperimentResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *experimentClientStub) CreateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
||||
s.createRequest = request
|
||||
return s.createResponse, s.err
|
||||
}
|
||||
|
||||
func (s *experimentClientStub) UpdateLuckyGiftExperiment(_ context.Context, request *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
||||
s.updateRequest = request
|
||||
return s.updateResponse, s.err
|
||||
}
|
||||
|
||||
func (s *experimentClientStub) SetLuckyGiftExperimentOverrides(_ context.Context, request *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
|
||||
s.overridesRequest = request
|
||||
return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: request.GetExperimentId(), Overrides: request.GetUpserts()},
|
||||
}, s.err
|
||||
}
|
||||
|
||||
func (s *experimentClientStub) EndLuckyGiftExperiment(_ context.Context, request *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
|
||||
s.endRequest = request
|
||||
return s.endResponse, s.err
|
||||
}
|
||||
|
||||
func experimentTestContext(t *testing.T, method, target, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(method, target, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set(middleware.ContextUserID, uint(7))
|
||||
c.Set(middleware.ContextRequestID, "request-1")
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
func TestCreateLuckyGiftExperimentReusesConfigNormalizationAndPPM(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := &experimentClientStub{createResponse: &luckygiftv1.CreateLuckyGiftExperimentResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{
|
||||
AppCode: "lalu", PoolId: "super_lucky", ExperimentId: "lucky_exp_1", Name: "rtp push",
|
||||
ControlRuleVersion: 9, TreatmentRuleVersion: 10, TreatmentTrafficPpm: 50_000, Status: "running",
|
||||
Overrides: []*luckygiftv1.LuckyGiftExperimentOverride{{UserKey: "1001", Arm: "treatment"}},
|
||||
ArmStats: []*luckygiftv1.LuckyGiftExperimentArmStats{{Arm: "control", RuleVersion: 9, ActualRtpPpm: 955_000}},
|
||||
},
|
||||
TreatmentConfig: &luckygiftv1.LuckyGiftRuleConfig{AppCode: "lalu", PoolId: "super_lucky", RuleVersion: 10, TargetRtpPpm: 960_000},
|
||||
}}
|
||||
body := `{
|
||||
"name": " rtp push ",
|
||||
"treatment_traffic_percent": 5,
|
||||
"overrides": [{"user_key": " 1001 ", "arm": " TREATMENT "}],
|
||||
"config": {
|
||||
"pool_id": "super_lucky",
|
||||
"strategy_version": "DYNAMIC_V3",
|
||||
"enabled": true,
|
||||
"target_rtp_percent": 96,
|
||||
"initial_pool_coins": 1000000,
|
||||
"stages": [{"stage": "normal", "tiers": [{"multiplier": 0.5, "probability_percent": 22, "enabled": true}]}]
|
||||
}
|
||||
}`
|
||||
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments?app_code=LALU", body)
|
||||
New(client, time.Second, nil).CreateLuckyGiftExperiment(c)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
request := client.createRequest
|
||||
if request == nil {
|
||||
t.Fatal("owner create request was not sent")
|
||||
}
|
||||
if request.GetName() != "rtp push" || request.GetTreatmentTrafficPpm() != 50_000 || request.GetOperatorAdminId() != 7 {
|
||||
t.Fatalf("owner request=%+v", request)
|
||||
}
|
||||
if request.GetMeta().GetAppCode() != "lalu" || request.GetMeta().GetRequestId() != "request-1" {
|
||||
t.Fatalf("owner request meta=%+v", request.GetMeta())
|
||||
}
|
||||
if len(request.GetOverrides()) != 1 || request.GetOverrides()[0].GetUserKey() != "1001" || request.GetOverrides()[0].GetArm() != "treatment" {
|
||||
t.Fatalf("owner request overrides=%+v", request.GetOverrides())
|
||||
}
|
||||
config := request.GetTreatmentConfig()
|
||||
if config.GetAppCode() != "lalu" || config.GetStrategyVersion() != "dynamic_v3" {
|
||||
t.Fatalf("treatment config app/strategy=%+v", config)
|
||||
}
|
||||
if config.GetTargetRtpPpm() != 960_000 {
|
||||
t.Fatalf("treatment config target rtp ppm=%d, want 960000", config.GetTargetRtpPpm())
|
||||
}
|
||||
if config.GetInitialPoolCoins() != 0 {
|
||||
t.Fatalf("dynamic_v3 initial pool coins should be forced to 0, got %d", config.GetInitialPoolCoins())
|
||||
}
|
||||
tier := config.GetStages()[0].GetTiers()[0]
|
||||
if tier.GetTierId() != "normal_0_5x" || tier.GetBaseWeightPpm() != 220_000 {
|
||||
t.Fatalf("treatment config tier=%+v", tier)
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Experiment struct {
|
||||
ExperimentID string `json:"experiment_id"`
|
||||
TreatmentTrafficPercent float64 `json:"treatment_traffic_percent"`
|
||||
ArmStats []struct {
|
||||
Arm string `json:"arm"`
|
||||
ActualRTPPercent float64 `json:"actual_rtp_percent"`
|
||||
} `json:"arm_stats"`
|
||||
} `json:"experiment"`
|
||||
TreatmentConfig struct {
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||
} `json:"treatment_config"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if envelope.Code != 0 || envelope.Data.Experiment.ExperimentID != "lucky_exp_1" || envelope.Data.Experiment.TreatmentTrafficPercent != 5 {
|
||||
t.Fatalf("response=%s", recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Data.Experiment.ArmStats) != 1 || envelope.Data.Experiment.ArmStats[0].ActualRTPPercent != 95.5 {
|
||||
t.Fatalf("arm stats response=%s", recorder.Body.String())
|
||||
}
|
||||
if envelope.Data.TreatmentConfig.RuleVersion != 10 || envelope.Data.TreatmentConfig.TargetRTPPercent != 96 {
|
||||
t.Fatalf("treatment config response=%s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLuckyGiftExperimentOmittedFieldsKeepOwnerDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
// 缺省流量以 proto3 optional 的"字段不存在"表达(wantTraffic=nil),显式 0 必须以存在的 0 传递;
|
||||
// 这是防止"仅改状态的调用把放量静默清零"的契约核心。
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantTraffic *int64
|
||||
wantStatus string
|
||||
}{
|
||||
{name: "omitted fields", body: `{}`, wantTraffic: nil, wantStatus: ""},
|
||||
{name: "explicit zero traffic", body: `{"treatment_traffic_percent": 0}`, wantTraffic: int64Ptr(0), wantStatus: ""},
|
||||
{name: "pause only", body: `{"status": "paused"}`, wantTraffic: nil, wantStatus: "paused"},
|
||||
{name: "both fields", body: `{"treatment_traffic_percent": 12.5, "status": "running"}`, wantTraffic: int64Ptr(125_000), wantStatus: "running"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &experimentClientStub{updateResponse: &luckygiftv1.UpdateLuckyGiftExperimentResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_1", Status: "running"},
|
||||
}}
|
||||
c, recorder := experimentTestContext(t, http.MethodPatch, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1?app_code=lalu", tt.body)
|
||||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||||
New(client, time.Second, nil).UpdateLuckyGiftExperiment(c)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
request := client.updateRequest
|
||||
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
|
||||
t.Fatalf("owner request=%+v", request)
|
||||
}
|
||||
if (request.TreatmentTrafficPpm == nil) != (tt.wantTraffic == nil) ||
|
||||
(tt.wantTraffic != nil && request.GetTreatmentTrafficPpm() != *tt.wantTraffic) ||
|
||||
request.GetStatus() != tt.wantStatus {
|
||||
t.Fatalf("traffic=%v status=%q, want traffic=%v status=%q", request.TreatmentTrafficPpm, request.GetStatus(), tt.wantTraffic, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 { return &v }
|
||||
|
||||
func TestSetLuckyGiftExperimentOverridesForwardsUpsertsAndRemoves(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := &experimentClientStub{}
|
||||
body := `{"upserts": [{"user_key": "ext:abc", "arm": "control"}], "removes": [" 1001 "]}`
|
||||
c, recorder := experimentTestContext(t, http.MethodPut, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/overrides?app_code=lalu", body)
|
||||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||||
New(client, time.Second, nil).SetLuckyGiftExperimentOverrides(c)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
request := client.overridesRequest
|
||||
if request == nil || request.GetExperimentId() != "lucky_exp_1" || request.GetOperatorAdminId() != 7 {
|
||||
t.Fatalf("owner request=%+v", request)
|
||||
}
|
||||
if len(request.GetUpserts()) != 1 || request.GetUpserts()[0].GetUserKey() != "ext:abc" || request.GetUpserts()[0].GetArm() != "control" {
|
||||
t.Fatalf("owner upserts=%+v", request.GetUpserts())
|
||||
}
|
||||
if len(request.GetRemoves()) != 1 || request.GetRemoves()[0] != "1001" {
|
||||
t.Fatalf("owner removes=%+v", request.GetRemoves())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Experiment struct {
|
||||
ExperimentID string `json:"experiment_id"`
|
||||
} `json:"experiment"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if envelope.Data.Experiment.ExperimentID != "lucky_exp_1" {
|
||||
t.Fatalf("response=%s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndLuckyGiftExperimentReturnsExperimentAndFinalConfig(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := &experimentClientStub{endResponse: &luckygiftv1.EndLuckyGiftExperimentResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{
|
||||
ExperimentId: "lucky_exp_1", Status: "completed", WinnerArm: "treatment",
|
||||
WinnerRuleVersion: 10, FinalRuleVersion: 11,
|
||||
},
|
||||
FinalConfig: &luckygiftv1.LuckyGiftRuleConfig{PoolId: "super_lucky", RuleVersion: 11, TargetRtpPpm: 960_000},
|
||||
}}
|
||||
c, recorder := experimentTestContext(t, http.MethodPost, "/admin/ops-center/lucky-gifts/experiments/lucky_exp_1/end?app_code=lalu", `{"winner_arm": "Treatment"}`)
|
||||
c.Params = gin.Params{{Key: "experimentId", Value: "lucky_exp_1"}}
|
||||
New(client, time.Second, nil).EndLuckyGiftExperiment(c)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if client.endRequest == nil || client.endRequest.GetWinnerArm() != "treatment" || client.endRequest.GetOperatorAdminId() != 7 {
|
||||
t.Fatalf("owner request=%+v", client.endRequest)
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Experiment struct {
|
||||
Status string `json:"status"`
|
||||
WinnerArm string `json:"winner_arm"`
|
||||
FinalRuleVersion int64 `json:"final_rule_version"`
|
||||
} `json:"experiment"`
|
||||
FinalConfig struct {
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
TargetRTPPercent float64 `json:"target_rtp_percent"`
|
||||
} `json:"final_config"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if envelope.Code != 0 || envelope.Data.Experiment.Status != "completed" || envelope.Data.Experiment.WinnerArm != "treatment" || envelope.Data.Experiment.FinalRuleVersion != 11 {
|
||||
t.Fatalf("experiment response=%s", recorder.Body.String())
|
||||
}
|
||||
if envelope.Data.FinalConfig.RuleVersion != 11 || envelope.Data.FinalConfig.TargetRTPPercent != 96 {
|
||||
t.Fatalf("final config response=%s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLuckyGiftExperimentsRequiresAppCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, recorder := experimentTestContext(t, http.MethodGet, "/admin/ops-center/lucky-gifts/experiments", "")
|
||||
New(&experimentClientStub{}, time.Second, nil).ListLuckyGiftExperiments(c)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want=400 body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@ -27,4 +27,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances)
|
||||
group.POST("/lucky-gifts/pools/credit", middleware.RequirePermission("lucky-gift:pool-credit"), h.luckyGift.CreditLuckyGiftPoolBalance)
|
||||
group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance)
|
||||
group.GET("/lucky-gifts/experiments", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftExperiments)
|
||||
group.POST("/lucky-gifts/experiments", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.CreateLuckyGiftExperiment)
|
||||
group.PATCH("/lucky-gifts/experiments/:experimentId", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.UpdateLuckyGiftExperiment)
|
||||
group.PUT("/lucky-gifts/experiments/:experimentId/overrides", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.SetLuckyGiftExperimentOverrides)
|
||||
group.POST("/lucky-gifts/experiments/:experimentId/end", middleware.RequirePermission("lucky-gift:experiment"), h.luckyGift.EndLuckyGiftExperiment)
|
||||
}
|
||||
|
||||
@ -42,6 +42,11 @@ func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) {
|
||||
for _, expected := range []string{
|
||||
"POST /api/v1/admin/ops-center/lucky-gifts/pools/credit",
|
||||
"POST /api/v1/admin/ops-center/lucky-gifts/pools/debit",
|
||||
"GET /api/v1/admin/ops-center/lucky-gifts/experiments",
|
||||
"POST /api/v1/admin/ops-center/lucky-gifts/experiments",
|
||||
"PATCH /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId",
|
||||
"PUT /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/overrides",
|
||||
"POST /api/v1/admin/ops-center/lucky-gifts/experiments/:experimentId/end",
|
||||
} {
|
||||
if _, ok := routes[expected]; !ok {
|
||||
t.Fatalf("missing route %s; actual=%v", expected, routes)
|
||||
@ -49,6 +54,95 @@ func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type experimentPermissionClient struct {
|
||||
luckygiftadmin.Client
|
||||
}
|
||||
|
||||
func (experimentPermissionClient) ListLuckyGiftExperiments(_ context.Context, _ *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) {
|
||||
return &luckygiftv1.ListLuckyGiftExperimentsResponse{}, nil
|
||||
}
|
||||
|
||||
func (experimentPermissionClient) CreateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
||||
return &luckygiftv1.CreateLuckyGiftExperimentResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: "lucky_exp_route", Name: req.GetName()},
|
||||
TreatmentConfig: req.GetTreatmentConfig(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (experimentPermissionClient) UpdateLuckyGiftExperiment(_ context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
||||
return &luckygiftv1.UpdateLuckyGiftExperimentResponse{
|
||||
Experiment: &luckygiftv1.LuckyGiftExperiment{ExperimentId: req.GetExperimentId()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestExperimentRoutesRequireExperimentPermission(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
permission string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "list allowed with view", method: http.MethodGet,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
||||
body: "", permission: "lucky-gift:view", wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "create allowed with experiment", method: http.MethodPost,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
||||
body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`,
|
||||
permission: "lucky-gift:experiment", wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "create rejects update permission", method: http.MethodPost,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
||||
body: `{"name":"exp","treatment_traffic_percent":5,"config":{"pool_id":"lucky"}}`,
|
||||
permission: "lucky-gift:update", wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "patch allowed with experiment", method: http.MethodPatch,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu",
|
||||
body: `{"status":"paused"}`, permission: "lucky-gift:experiment", wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "patch rejects view permission", method: http.MethodPatch,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route?app_code=lalu",
|
||||
body: `{"status":"paused"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "end rejects view permission", method: http.MethodPost,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/end?app_code=lalu",
|
||||
body: `{"winner_arm":"control"}`, permission: "lucky-gift:view", wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "overrides rejects update permission", method: http.MethodPut,
|
||||
target: "/api/v1/admin/ops-center/lucky-gifts/experiments/lucky_exp_route/overrides?app_code=lalu",
|
||||
body: `{"upserts":[]}`, permission: "lucky-gift:update", wantStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.ContextPermissions, []string{tt.permission})
|
||||
c.Set(middleware.ContextUserID, uint(7))
|
||||
c.Next()
|
||||
})
|
||||
RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(experimentPermissionClient{}, 0, nil)))
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != tt.wantStatus {
|
||||
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
|
||||
@ -140,7 +140,7 @@ var (
|
||||
"wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
|
||||
"agency-opening:create", "agency-opening:update", "red-packet:update",
|
||||
"cp-config:update", "cp-weekly-rank:update", "vip-config:update",
|
||||
"achievement:create", "achievement:update", "lucky-gift:update",
|
||||
"achievement:create", "achievement:update", "lucky-gift:update", "lucky-gift:experiment",
|
||||
}
|
||||
// 奖池人工增减属于资金高风险动作,只授予运营负责人;platform-admin 仍由全权限同步自动获得。
|
||||
luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}
|
||||
|
||||
@ -204,6 +204,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
||||
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
||||
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
|
||||
{Name: "幸运礼物实验管理", Code: "lucky-gift:experiment", Kind: "button"},
|
||||
{Name: "幸运礼物奖池注资", Code: "lucky-gift:pool-credit", Kind: "button"},
|
||||
{Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"},
|
||||
{Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"},
|
||||
|
||||
@ -162,16 +162,16 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) {
|
||||
|
||||
func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) {
|
||||
assertRolePermissions(t, roleCodeOperationsLead,
|
||||
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil,
|
||||
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil,
|
||||
)
|
||||
assertRolePermissions(t, roleCodeOperationsSpecialist,
|
||||
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
)
|
||||
assertRolePermissions(t, roleCodeProductLead,
|
||||
[]string{"lucky-gift:view", "lucky-gift:update"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
[]string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:experiment"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
)
|
||||
assertRolePermissions(t, roleCodeProductSpecialist,
|
||||
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
[]string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:experiment", "lucky-gift:pool-credit", "lucky-gift:pool-debit"},
|
||||
)
|
||||
}
|
||||
|
||||
@ -358,6 +358,28 @@ func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuckyGiftExperimentPermissionMigrationGrantsUpdateRoleGroup(t *testing.T) {
|
||||
content, err := os.ReadFile("../../migrations/103_lucky_gift_experiment_permission.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read lucky gift experiment permission migration: %v", err)
|
||||
}
|
||||
sqlText := string(content)
|
||||
for _, token := range []string{
|
||||
"'lucky-gift:experiment'",
|
||||
"'platform-admin', 'ops-admin', 'product-lead'",
|
||||
} {
|
||||
if !strings.Contains(sqlText, token) {
|
||||
t.Fatalf("lucky gift experiment permission migration missing %s", token)
|
||||
}
|
||||
}
|
||||
// 实验会发布规则版本,但不是资金动作;不能顺手把奖池调账角色组扩进来。
|
||||
for _, token := range []string{"'operations-specialist'", "'product-specialist'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'"} {
|
||||
if strings.Contains(sqlText, token) {
|
||||
t.Fatalf("lucky gift experiment permission migration must not touch %s", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
|
||||
t.Helper()
|
||||
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
|
||||
@ -374,7 +396,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin
|
||||
"activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish",
|
||||
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry",
|
||||
"external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password",
|
||||
"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit",
|
||||
"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit", "lucky-gift:experiment",
|
||||
})
|
||||
if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
|
||||
// 093 仍记录旧的财务提现权限;102 才把固定运营岗位切换到独立运营审核权限。
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
-- 幸运礼物 AB 实验管理是独立按钮权限:创建/调量/白名单/结束实验都会发布或钉住规则版本,
|
||||
-- 权限边界与 lucky-gift:update 一致(platform-admin、ops-admin、product-lead),查看仍复用 lucky-gift:view。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('幸运礼物实验管理', 'lucky-gift:experiment', 'button', '允许创建、放量、暂停、白名单和结束幸运礼物 AB 实验', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT role.id, permission.id
|
||||
FROM admin_roles role
|
||||
JOIN admin_permissions permission
|
||||
WHERE role.code IN ('platform-admin', 'ops-admin', 'product-lead')
|
||||
AND permission.code = 'lucky-gift:experiment';
|
||||
@ -309,7 +309,8 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records (
|
||||
KEY idx_lucky_draw_status (app_code, reward_status, updated_at_ms),
|
||||
KEY idx_lucky_draw_created (app_code, created_at_ms, draw_id),
|
||||
KEY idx_lucky_draw_pool_status_summary (app_code, pool_id, reward_status, created_at_ms, draw_id),
|
||||
KEY idx_lucky_draw_gift_status_summary (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id)
|
||||
KEY idx_lucky_draw_gift_status_summary (app_code, pool_id, gift_id, reward_status, created_at_ms, draw_id),
|
||||
KEY idx_lucky_draw_pool_version (app_code, pool_id, rule_version, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物抽奖事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lucky_draw_pool_stats (
|
||||
@ -431,3 +432,41 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
|
||||
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
|
||||
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
|
||||
|
||||
-- AB 实验事实表:两组各钉住一个不可变 rule_version,运行时按 bucket_salt + user 哈希分流。
|
||||
-- active 生成列只在未完成状态为 1,配合唯一键保证同一奖池同时最多一个未完成实验。
|
||||
CREATE TABLE IF NOT EXISTS lucky_gift_experiments (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID',
|
||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||
name VARCHAR(128) NOT NULL COMMENT '实验名称',
|
||||
control_rule_version BIGINT NOT NULL COMMENT '对照组钉住的规则版本',
|
||||
treatment_rule_version BIGINT NOT NULL COMMENT '实验组钉住的规则版本',
|
||||
treatment_traffic_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '实验组流量比例 ppm,0..1000000',
|
||||
bucket_salt VARCHAR(64) NOT NULL COMMENT '分桶盐;实验生命周期内不可变,保证同一用户分组稳定',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running/paused/completed',
|
||||
active TINYINT AS (IF(status IN ('running', 'paused'), 1, NULL)) STORED COMMENT '未完成实验为 1,completed 为 NULL',
|
||||
winner_arm VARCHAR(16) NOT NULL DEFAULT '' COMMENT '结束实验时选择的获胜组 control/treatment',
|
||||
winner_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '获胜组钉住的规则版本',
|
||||
final_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '结束时重发布的收敛版本,全量流量由它承接',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
|
||||
started_at_ms BIGINT NOT NULL COMMENT '实验开始时间,UTC epoch ms',
|
||||
ended_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实验结束时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, experiment_id),
|
||||
UNIQUE KEY uk_lucky_gift_experiments_active_pool (app_code, pool_id, active),
|
||||
KEY idx_lucky_gift_experiments_pool (app_code, pool_id, started_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验';
|
||||
|
||||
-- 白名单 override:把指定用户强制固定到某个组,用于放量前验证。
|
||||
CREATE TABLE IF NOT EXISTS lucky_gift_experiment_overrides (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID',
|
||||
-- user_key 必须字节精确(binary collation):FNV 分桶按字节计算,ci 排序规则会让大小写不同的外部用户碰撞/失配。
|
||||
user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户标识:内部十进制 user_id 或 ext:external_user_id',
|
||||
arm VARCHAR(16) NOT NULL COMMENT '强制组 control/treatment',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, experiment_id, user_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验用户白名单';
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_lucky_gift;
|
||||
|
||||
-- AB 实验事实表:两组各钉住一个不可变 rule_version,运行时按 bucket_salt + user 哈希分流。
|
||||
-- active 生成列只在未完成状态为 1,配合唯一键保证同一奖池同时最多一个未完成实验;
|
||||
-- completed 后 active 为 NULL,不占用唯一键,历史实验可以保留任意多条。
|
||||
CREATE TABLE IF NOT EXISTS lucky_gift_experiments (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID',
|
||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||
name VARCHAR(128) NOT NULL COMMENT '实验名称',
|
||||
control_rule_version BIGINT NOT NULL COMMENT '对照组钉住的规则版本',
|
||||
treatment_rule_version BIGINT NOT NULL COMMENT '实验组钉住的规则版本',
|
||||
treatment_traffic_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '实验组流量比例 ppm,0..1000000',
|
||||
bucket_salt VARCHAR(64) NOT NULL COMMENT '分桶盐;实验生命周期内不可变,保证同一用户分组稳定',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running' COMMENT 'running/paused/completed',
|
||||
active TINYINT AS (IF(status IN ('running', 'paused'), 1, NULL)) STORED COMMENT '未完成实验为 1,completed 为 NULL',
|
||||
winner_arm VARCHAR(16) NOT NULL DEFAULT '' COMMENT '结束实验时选择的获胜组 control/treatment',
|
||||
winner_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '获胜组钉住的规则版本',
|
||||
final_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '结束时重发布的收敛版本,全量流量由它承接',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
|
||||
started_at_ms BIGINT NOT NULL COMMENT '实验开始时间,UTC epoch ms',
|
||||
ended_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实验结束时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, experiment_id),
|
||||
UNIQUE KEY uk_lucky_gift_experiments_active_pool (app_code, pool_id, active),
|
||||
KEY idx_lucky_gift_experiments_pool (app_code, pool_id, started_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验';
|
||||
|
||||
-- 白名单 override:把指定用户强制固定到某个组,用于放量前验证。
|
||||
-- user_key:内部用户为十进制 user_id;外部用户为 "ext:" + external_user_id。
|
||||
CREATE TABLE IF NOT EXISTS lucky_gift_experiment_overrides (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
experiment_id VARCHAR(64) NOT NULL COMMENT '实验 ID',
|
||||
-- user_key 必须字节精确(binary collation):FNV 分桶按字节计算,ci 排序规则会让大小写不同的外部用户在白名单上碰撞/静默失配。
|
||||
user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户标识:内部十进制 user_id 或 ext:external_user_id',
|
||||
arm VARCHAR(16) NOT NULL COMMENT '强制组 control/treatment',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, experiment_id, user_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物 AB 实验用户白名单';
|
||||
|
||||
-- 实验分组统计按 (app, pool, rule_version, 时间窗) 聚合;没有含 rule_version 的索引时热池会退化为大范围回表扫描。
|
||||
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_draw_records' AND INDEX_NAME = 'idx_lucky_draw_pool_version') = 0,
|
||||
'ALTER TABLE lucky_draw_records ADD KEY idx_lucky_draw_pool_version (app_code, pool_id, rule_version, created_at_ms)', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 兜底:006 首版曾以表默认 ci 排序规则建过 overrides 表;重复执行时归一到 binary。
|
||||
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_experiment_overrides' AND COLUMN_NAME = 'user_key' AND COLLATION_NAME <> 'utf8mb4_bin') = 1,
|
||||
'ALTER TABLE lucky_gift_experiment_overrides MODIFY user_key VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ''用户标识:内部十进制 user_id 或 ext:external_user_id''', 'SELECT 1');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -0,0 +1,156 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// 实验组常量。control 钉住实验开始时的最新已启用版本,treatment 钉住随实验原子发布的新版本。
|
||||
ExperimentArmControl = "control"
|
||||
ExperimentArmTreatment = "treatment"
|
||||
|
||||
// paused 时全部流量(含白名单 override)回落 control,作为不销毁分组事实的紧急停闸。
|
||||
ExperimentStatusRunning = "running"
|
||||
ExperimentStatusPaused = "paused"
|
||||
ExperimentStatusCompleted = "completed"
|
||||
|
||||
// ExperimentTrafficPPMScale 是 treatment 放量比例的满量程;与全仓 ppm 口径一致。
|
||||
ExperimentTrafficPPMScale int64 = 1_000_000
|
||||
|
||||
// MaxExperimentOverrides 是单实验白名单的累计上限(单次请求与库内总量都受它约束)。
|
||||
// 白名单是放量前的验证工具,不是花名册式定向投放。
|
||||
MaxExperimentOverrides = 500
|
||||
)
|
||||
|
||||
// Experiment 是单奖池 AB 实验事实。两组各自钉住一个不可变 rule_version,
|
||||
// 运行中"最新版本生效"的默认语义被实验钉住的版本替代;同一奖池最多一个未完成实验。
|
||||
type Experiment struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PoolID string `json:"pool_id"`
|
||||
ExperimentID string `json:"experiment_id"`
|
||||
Name string `json:"name"`
|
||||
ControlRuleVersion int64 `json:"control_rule_version"`
|
||||
TreatmentRuleVersion int64 `json:"treatment_rule_version"`
|
||||
// TreatmentTrafficPPM ∈ [0, 1000000];0 表示只有白名单 override 进入 treatment。
|
||||
TreatmentTrafficPPM int64 `json:"treatment_traffic_ppm"`
|
||||
BucketSalt string `json:"bucket_salt"`
|
||||
Status string `json:"status"`
|
||||
WinnerArm string `json:"winner_arm"`
|
||||
WinnerRuleVersion int64 `json:"winner_rule_version"`
|
||||
// FinalRuleVersion 是结束实验时把获胜配置重发布成的新最新版本;全量流量由它承接。
|
||||
FinalRuleVersion int64 `json:"final_rule_version"`
|
||||
CreatedByAdminID int64 `json:"created_by_admin_id"`
|
||||
StartedAtMS int64 `json:"started_at_ms"`
|
||||
EndedAtMS int64 `json:"ended_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Overrides []ExperimentOverride `json:"overrides,omitempty"`
|
||||
ArmStats []ExperimentArmStats `json:"arm_stats,omitempty"`
|
||||
}
|
||||
|
||||
// ExperimentOverride 把单个用户强制固定到某个实验组,多用于放量前的白名单验证。
|
||||
type ExperimentOverride struct {
|
||||
UserKey string `json:"user_key"`
|
||||
Arm string `json:"arm"`
|
||||
}
|
||||
|
||||
// ExperimentArmStats 是实验开始以来单组的抽奖聚合,按 rule_version 从抽奖事实现算。
|
||||
type ExperimentArmStats struct {
|
||||
Arm string `json:"arm"`
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
StrategyVersion string `json:"strategy_version"`
|
||||
TotalDraws int64 `json:"total_draws"`
|
||||
UniqueUsers int64 `json:"unique_users"`
|
||||
TotalSpentCoins int64 `json:"total_spent_coins"`
|
||||
TotalRewardCoins int64 `json:"total_reward_coins"`
|
||||
ActualRTPPPM int64 `json:"actual_rtp_ppm"`
|
||||
}
|
||||
|
||||
type CreateExperimentCommand struct {
|
||||
Name string
|
||||
TreatmentConfig RuleConfig
|
||||
TreatmentTrafficPPM int64
|
||||
OperatorAdminID int64
|
||||
Overrides []ExperimentOverride
|
||||
}
|
||||
|
||||
type UpdateExperimentCommand struct {
|
||||
ExperimentID string
|
||||
// TreatmentTrafficPPM < 0 表示不修改流量。
|
||||
TreatmentTrafficPPM int64
|
||||
// Status 为空表示不修改状态;仅允许 running/paused 之间切换。
|
||||
Status string
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
type SetExperimentOverridesCommand struct {
|
||||
ExperimentID string
|
||||
Upserts []ExperimentOverride
|
||||
Removes []string
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
type EndExperimentCommand struct {
|
||||
ExperimentID string
|
||||
WinnerArm string
|
||||
OperatorAdminID int64
|
||||
}
|
||||
|
||||
type ExperimentQuery struct {
|
||||
AppCode string
|
||||
PoolID string
|
||||
Status string
|
||||
WithStats bool
|
||||
}
|
||||
|
||||
// ExperimentUserKey 归一实验分桶与白名单使用的用户标识:
|
||||
// 内部用户为十进制 user_id,外部用户为 "ext:" + external_user_id。
|
||||
func ExperimentUserKey(userID int64) string {
|
||||
return strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func ExperimentExternalUserKey(externalUserID string) string {
|
||||
return "ext:" + strings.TrimSpace(externalUserID)
|
||||
}
|
||||
|
||||
// ExperimentBucketPPM 把用户确定性地映射到 [0, 1000000) 的分桶坐标。
|
||||
// 对同一 salt+userKey 必须稳定:实验状态不变时 Check 与 Draw 解析到同一组。
|
||||
// 放量/白名单/暂停操作仍会让分组在两次解析间漂移——这正是创建实验强制两组同策略的原因:
|
||||
// 同策略下漂移只是参数差异,不会击穿 room saga 固化 strategy_version 后对 device/paid_at 的前置拦截。
|
||||
func ExperimentBucketPPM(salt string, userKey string) int64 {
|
||||
hasher := fnv.New64a()
|
||||
_, _ = hasher.Write([]byte(salt))
|
||||
_, _ = hasher.Write([]byte{':'})
|
||||
_, _ = hasher.Write([]byte(userKey))
|
||||
return int64(hasher.Sum64() % uint64(ExperimentTrafficPPMScale))
|
||||
}
|
||||
|
||||
// ResolveExperimentArm 返回用户所属实验组。
|
||||
// 优先级:paused 全量 control > 白名单 override > 哈希放量。
|
||||
// 放量比较使用 bucket < traffic:调大流量时既有 treatment 用户全部保留,只有新用户迁入,保证单调放量。
|
||||
func ResolveExperimentArm(experiment Experiment, userKey string, override string) string {
|
||||
if experiment.Status != ExperimentStatusRunning {
|
||||
return ExperimentArmControl
|
||||
}
|
||||
switch override {
|
||||
case ExperimentArmControl, ExperimentArmTreatment:
|
||||
return override
|
||||
}
|
||||
if ExperimentBucketPPM(experiment.BucketSalt, userKey) < experiment.TreatmentTrafficPPM {
|
||||
return ExperimentArmTreatment
|
||||
}
|
||||
return ExperimentArmControl
|
||||
}
|
||||
|
||||
// ExperimentArmRuleVersion 返回某组钉住的配置版本。
|
||||
func ExperimentArmRuleVersion(experiment Experiment, arm string) int64 {
|
||||
if arm == ExperimentArmTreatment {
|
||||
return experiment.TreatmentRuleVersion
|
||||
}
|
||||
return experiment.ControlRuleVersion
|
||||
}
|
||||
|
||||
func ValidExperimentArm(arm string) bool {
|
||||
return arm == ExperimentArmControl || arm == ExperimentArmTreatment
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package luckygift
|
||||
|
||||
import "testing"
|
||||
|
||||
// 分桶必须纯确定:room saga 在 Check 固化 strategy_version 且恢复不重新 Check,
|
||||
// 同一用户在 Check 与 Draw 的两次解析必须得到同一组。
|
||||
func TestExperimentBucketPPMIsDeterministic(t *testing.T) {
|
||||
for _, userKey := range []string{"1", "42", "9007199254740993", "ext:abc-123"} {
|
||||
first := ExperimentBucketPPM("salt-a", userKey)
|
||||
for i := 0; i < 10; i++ {
|
||||
if again := ExperimentBucketPPM("salt-a", userKey); again != first {
|
||||
t.Fatalf("bucket for %q drifted: %d != %d", userKey, again, first)
|
||||
}
|
||||
}
|
||||
if first < 0 || first >= ExperimentTrafficPPMScale {
|
||||
t.Fatalf("bucket for %q out of range: %d", userKey, first)
|
||||
}
|
||||
}
|
||||
if ExperimentBucketPPM("salt-a", "1") == ExperimentBucketPPM("salt-b", "1") &&
|
||||
ExperimentBucketPPM("salt-a", "2") == ExperimentBucketPPM("salt-b", "2") &&
|
||||
ExperimentBucketPPM("salt-a", "3") == ExperimentBucketPPM("salt-b", "3") {
|
||||
t.Fatal("different salts should reshuffle buckets")
|
||||
}
|
||||
}
|
||||
|
||||
// 放量必须单调:调大流量时既有 treatment 用户全部保留,不允许来回倒组打断用户体验状态。
|
||||
func TestResolveExperimentArmMonotonicRamp(t *testing.T) {
|
||||
low := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 50_000}
|
||||
high := low
|
||||
high.TreatmentTrafficPPM = 300_000
|
||||
treatmentAtLow := 0
|
||||
for userID := int64(1); userID <= 2_000; userID++ {
|
||||
key := ExperimentUserKey(userID)
|
||||
lowArm := ResolveExperimentArm(low, key, "")
|
||||
highArm := ResolveExperimentArm(high, key, "")
|
||||
if lowArm == ExperimentArmTreatment {
|
||||
treatmentAtLow++
|
||||
if highArm != ExperimentArmTreatment {
|
||||
t.Fatalf("user %d left treatment when traffic increased", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if treatmentAtLow == 0 {
|
||||
t.Fatal("5% ramp assigned nobody to treatment in a 2000-user sample")
|
||||
}
|
||||
}
|
||||
|
||||
// 分桶应大致均匀:50% 流量在 10000 用户样本上偏差不应超过 ±5 个百分点。
|
||||
func TestExperimentBucketDistribution(t *testing.T) {
|
||||
experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "distribution", TreatmentTrafficPPM: 500_000}
|
||||
treatment := 0
|
||||
const sample = 10_000
|
||||
for userID := int64(1); userID <= sample; userID++ {
|
||||
if ResolveExperimentArm(experiment, ExperimentUserKey(userID), "") == ExperimentArmTreatment {
|
||||
treatment++
|
||||
}
|
||||
}
|
||||
if treatment < sample*45/100 || treatment > sample*55/100 {
|
||||
t.Fatalf("treatment share %d/%d deviates from 50%% beyond tolerance", treatment, sample)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExperimentArmPrecedence(t *testing.T) {
|
||||
experiment := Experiment{Status: ExperimentStatusRunning, BucketSalt: "s", TreatmentTrafficPPM: 0}
|
||||
if arm := ResolveExperimentArm(experiment, "7", ExperimentArmTreatment); arm != ExperimentArmTreatment {
|
||||
t.Fatalf("override treatment ignored, got %s", arm)
|
||||
}
|
||||
full := experiment
|
||||
full.TreatmentTrafficPPM = ExperimentTrafficPPMScale
|
||||
if arm := ResolveExperimentArm(full, "7", ExperimentArmControl); arm != ExperimentArmControl {
|
||||
t.Fatalf("override control ignored at 100%% traffic, got %s", arm)
|
||||
}
|
||||
if arm := ResolveExperimentArm(full, "7", ""); arm != ExperimentArmTreatment {
|
||||
t.Fatalf("100%% traffic should send everyone to treatment, got %s", arm)
|
||||
}
|
||||
// paused 是紧急停闸:包括白名单在内的全部流量回落 control。
|
||||
paused := full
|
||||
paused.Status = ExperimentStatusPaused
|
||||
if arm := ResolveExperimentArm(paused, "7", ExperimentArmTreatment); arm != ExperimentArmControl {
|
||||
t.Fatalf("paused experiment must send everyone to control, got %s", arm)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package luckygift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||
)
|
||||
|
||||
|
||||
// CreateExperiment 原子完成 treatment 发布与实验登记。treatment 配置必须直接可运行(enabled),
|
||||
// 否则实验组用户在送礼预检查就会被 RuleNotActive 拦截,实验退化成按人关停功能。
|
||||
func (s *Service) CreateExperiment(ctx context.Context, cmd domain.CreateExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
cmd.Name = strings.TrimSpace(cmd.Name)
|
||||
if cmd.Name == "" || utf8.RuneCountInString(cmd.Name) > 64 {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment name must be 1-64 characters")
|
||||
}
|
||||
if cmd.TreatmentTrafficPPM < 0 || cmd.TreatmentTrafficPPM > domain.ExperimentTrafficPPMScale {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment treatment traffic must be between 0 and 1000000 ppm")
|
||||
}
|
||||
cmd.TreatmentConfig.AppCode = appcode.FromContext(ctx)
|
||||
cmd.TreatmentConfig.PoolID = normalizePoolID(cmd.TreatmentConfig.PoolID)
|
||||
cmd.TreatmentConfig = normalizeRuleConfig(cmd.TreatmentConfig)
|
||||
if !cmd.TreatmentConfig.Enabled {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment treatment config must be enabled")
|
||||
}
|
||||
if err := validateRuleConfig(cmd.TreatmentConfig); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
overrides, err := normalizeExperimentOverrides(cmd.Overrides)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
cmd.Overrides = overrides
|
||||
return s.repository.CreateLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *Service) ListExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(query.AppCode) == "" {
|
||||
query.AppCode = appcode.FromContext(ctx)
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(query.Status)); status != "" {
|
||||
if status != domain.ExperimentStatusRunning && status != domain.ExperimentStatusPaused && status != domain.ExperimentStatusCompleted {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "invalid experiment status filter")
|
||||
}
|
||||
query.Status = status
|
||||
}
|
||||
return s.repository.ListLuckyGiftExperiments(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand) (domain.Experiment, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||||
if cmd.ExperimentID == "" {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||||
}
|
||||
if cmd.TreatmentTrafficPPM > domain.ExperimentTrafficPPMScale {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment treatment traffic must be between 0 and 1000000 ppm")
|
||||
}
|
||||
cmd.Status = strings.ToLower(strings.TrimSpace(cmd.Status))
|
||||
if cmd.Status != "" && cmd.Status != domain.ExperimentStatusRunning && cmd.Status != domain.ExperimentStatusPaused {
|
||||
// completed 只能经 EndExperiment 收口:结束必须同时重发布获胜版本,不能只改状态。
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment status can only switch between running and paused")
|
||||
}
|
||||
if cmd.TreatmentTrafficPPM < 0 && cmd.Status == "" {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment update has nothing to change")
|
||||
}
|
||||
return s.repository.UpdateLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *Service) SetExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand) (domain.Experiment, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||||
if cmd.ExperimentID == "" {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||||
}
|
||||
upserts, err := normalizeExperimentOverrides(cmd.Upserts)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
cmd.Upserts = upserts
|
||||
removes := make([]string, 0, len(cmd.Removes))
|
||||
for _, userKey := range cmd.Removes {
|
||||
if userKey = strings.TrimSpace(userKey); userKey != "" {
|
||||
removes = append(removes, userKey)
|
||||
}
|
||||
}
|
||||
cmd.Removes = removes
|
||||
if len(cmd.Upserts) == 0 && len(cmd.Removes) == 0 {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment override update has nothing to change")
|
||||
}
|
||||
return s.repository.SetLuckyGiftExperimentOverrides(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func (s *Service) EndExperiment(ctx context.Context, cmd domain.EndExperimentCommand) (domain.Experiment, domain.RuleConfig, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
cmd.ExperimentID = strings.TrimSpace(cmd.ExperimentID)
|
||||
if cmd.ExperimentID == "" {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment id is required")
|
||||
}
|
||||
cmd.WinnerArm = strings.ToLower(strings.TrimSpace(cmd.WinnerArm))
|
||||
if !domain.ValidExperimentArm(cmd.WinnerArm) {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "experiment winner arm must be control or treatment")
|
||||
}
|
||||
return s.repository.EndLuckyGiftExperiment(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func normalizeExperimentOverrides(overrides []domain.ExperimentOverride) ([]domain.ExperimentOverride, error) {
|
||||
if len(overrides) > domain.MaxExperimentOverrides {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "experiment overrides exceed the 500 user limit")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(overrides))
|
||||
out := make([]domain.ExperimentOverride, 0, len(overrides))
|
||||
for _, override := range overrides {
|
||||
override.UserKey = strings.TrimSpace(override.UserKey)
|
||||
override.Arm = strings.ToLower(strings.TrimSpace(override.Arm))
|
||||
if override.UserKey == "" || utf8.RuneCountInString(override.UserKey) > 128 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "experiment override user key must be 1-128 characters")
|
||||
}
|
||||
if !domain.ValidExperimentArm(override.Arm) {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "experiment override arm must be control or treatment")
|
||||
}
|
||||
if _, exists := seen[override.UserKey]; exists {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "experiment override user key is duplicated: "+override.UserKey)
|
||||
}
|
||||
seen[override.UserKey] = struct{}{}
|
||||
out = append(out, override)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@ -31,6 +31,11 @@ type Repository interface {
|
||||
GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error)
|
||||
PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error)
|
||||
ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error)
|
||||
CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error)
|
||||
ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error)
|
||||
UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error)
|
||||
SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error)
|
||||
EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error)
|
||||
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
|
||||
ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error)
|
||||
|
||||
@ -94,7 +94,8 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
|
||||
}
|
||||
|
||||
poolID := luckyPoolID(cmd.PoolID)
|
||||
rule, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, cmd.AppCode, poolID, false)
|
||||
// 外部用户与内部用户共用实验分流,只是 userKey 前缀不同;同一 external_user_id 的分组同样确定。
|
||||
rule, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, cmd.AppCode, poolID, domain.ExperimentExternalUserKey(cmd.ExternalUserID), false)
|
||||
if err != nil {
|
||||
return domain.ExternalDrawResult{}, err
|
||||
}
|
||||
|
||||
@ -0,0 +1,577 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||
)
|
||||
|
||||
const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name,
|
||||
control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt,
|
||||
status, winner_arm, winner_rule_version, final_rule_version,
|
||||
created_by_admin_id, started_at_ms, ended_at_ms, updated_at_ms`
|
||||
|
||||
// resolveLuckyGiftRuleConfigForUser 是实验期的配置选择入口:
|
||||
// 无未完成实验时保持"最新版本生效"的原始语义;有实验时按白名单和哈希分桶钉住组内版本。
|
||||
// 实验行使用一致性快照读(不加锁):给它加锁会与创建/结束实验的"规则行→实验行"锁序构成 AB-BA。
|
||||
// 已知取舍:创建实验提交瞬间,快照早于提交而规则行锁读晚于提交的在途抽奖,会按"最新版本=treatment"
|
||||
// 开奖一次——毫秒级窗口,两组配置都是已校验可运行的版本,无资金风险,仅统计上可忽略的归组误差。
|
||||
// 实验状态(放量/白名单/暂停)变更会让分组在 Check 与 Draw 之间漂移;创建入口强制两组同策略,漂移无害。
|
||||
// 钉住的版本行缺失表示不可变事实被外部破坏,必须 fail-close,不能静默回落最新版本改变实验语义。
|
||||
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
|
||||
experiment, exists, err := r.getActiveLuckyGiftExperiment(ctx, q, appCode, poolID, false)
|
||||
if err != nil {
|
||||
return domain.RuleConfig{}, false, err
|
||||
}
|
||||
if !exists {
|
||||
return r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, forUpdate)
|
||||
}
|
||||
override := ""
|
||||
if experiment.Status == domain.ExperimentStatusRunning {
|
||||
override, err = r.getLuckyGiftExperimentOverrideArm(ctx, q, appCode, experiment.ExperimentID, userKey)
|
||||
if err != nil {
|
||||
return domain.RuleConfig{}, false, err
|
||||
}
|
||||
}
|
||||
arm := domain.ResolveExperimentArm(experiment, userKey, override)
|
||||
ruleVersion := domain.ExperimentArmRuleVersion(experiment, arm)
|
||||
config, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, q, appCode, poolID, ruleVersion, forUpdate)
|
||||
if err != nil {
|
||||
return domain.RuleConfig{}, false, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.RuleConfig{}, false, xerr.New(xerr.Internal, "lucky gift experiment pinned rule version is missing")
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getActiveLuckyGiftExperiment(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.Experiment, bool, error) {
|
||||
lockSQL := ""
|
||||
if forUpdate {
|
||||
lockSQL = " FOR UPDATE"
|
||||
}
|
||||
row := q.QueryRowContext(ctx, `
|
||||
SELECT `+luckyExperimentSelectColumns+`
|
||||
FROM lucky_gift_experiments
|
||||
WHERE app_code = ? AND pool_id = ? AND active = 1`+lockSQL,
|
||||
appCode, poolID,
|
||||
)
|
||||
experiment, err := scanLuckyGiftExperiment(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Experiment{}, false, nil
|
||||
}
|
||||
return domain.Experiment{}, false, err
|
||||
}
|
||||
return experiment, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getLuckyGiftExperimentByID(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, forUpdate bool) (domain.Experiment, bool, error) {
|
||||
lockSQL := ""
|
||||
if forUpdate {
|
||||
lockSQL = " FOR UPDATE"
|
||||
}
|
||||
row := q.QueryRowContext(ctx, `
|
||||
SELECT `+luckyExperimentSelectColumns+`
|
||||
FROM lucky_gift_experiments
|
||||
WHERE app_code = ? AND experiment_id = ?`+lockSQL,
|
||||
appCode, experimentID,
|
||||
)
|
||||
experiment, err := scanLuckyGiftExperiment(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Experiment{}, false, nil
|
||||
}
|
||||
return domain.Experiment{}, false, err
|
||||
}
|
||||
return experiment, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getLuckyGiftExperimentOverrideArm(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, userKey string) (string, error) {
|
||||
var arm string
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT arm FROM lucky_gift_experiment_overrides
|
||||
WHERE app_code = ? AND experiment_id = ? AND user_key = ?`,
|
||||
appCode, experimentID, userKey,
|
||||
).Scan(&arm)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return arm, nil
|
||||
}
|
||||
|
||||
// CreateLuckyGiftExperiment 在一个事务里发布 treatment 新版本并登记实验。
|
||||
// 发布与登记必须原子完成:先发布后建实验会有一个"最新版本生效"窗口把 treatment 全量放开。
|
||||
func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
poolID := luckyPoolID(cmd.TreatmentConfig.PoolID)
|
||||
// 锁住当前最新版本行:control 钉住它,同时与并发发布、并发建实验在同一把锁上互斥。
|
||||
control, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment requires a published config to pin as control")
|
||||
}
|
||||
if !control.Enabled {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment control version must be enabled")
|
||||
}
|
||||
// 两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check,而分组会随放量/白名单/
|
||||
// 暂停操作漂移;若两组策略不同,已扣费命令可能翻进 dynamic_v3 组后被 device/paid_at fail-close 永久卡死。
|
||||
// 同策略下分组漂移只是参数差异,两组对命令的强校验要求一致,漂移无害。fixed_v2 -> dynamic_v3 的策略迁移
|
||||
// 走常规发布(room 端已有滚动发布防护),不走实验;何况旧客户端无法进入 dynamic 组,混合策略实验的分组本身就有偏差。
|
||||
if control.StrategyVersion != cmd.TreatmentConfig.StrategyVersion {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share the same strategy version; migrate strategy via a regular publish instead")
|
||||
}
|
||||
// 锁读确保看见其他事务刚提交的实验行;同奖池并发建实验最终也会被唯一键兜底拒绝。
|
||||
// 已知取舍:不同奖池并发建实验可能在该唯一索引的共享间隙上互持 gap lock 后插入,触发 InnoDB 死锁检测
|
||||
// 回滚其一——管理端低频操作,报错重试即可,不为此引入额外串行化。
|
||||
if _, running, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
} else if running {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool already has an active experiment")
|
||||
}
|
||||
treatment, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, cmd.TreatmentConfig, nowMS)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
experiment := domain.Experiment{
|
||||
AppCode: appCode,
|
||||
PoolID: poolID,
|
||||
ExperimentID: idgen.New("lucky_exp"),
|
||||
Name: cmd.Name,
|
||||
ControlRuleVersion: control.RuleVersion,
|
||||
TreatmentRuleVersion: treatment.RuleVersion,
|
||||
TreatmentTrafficPPM: cmd.TreatmentTrafficPPM,
|
||||
BucketSalt: idgen.New("bs"),
|
||||
Status: domain.ExperimentStatusRunning,
|
||||
CreatedByAdminID: cmd.OperatorAdminID,
|
||||
StartedAtMS: nowMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO lucky_gift_experiments (
|
||||
app_code, experiment_id, pool_id, name,
|
||||
control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt,
|
||||
status, winner_arm, winner_rule_version, final_rule_version,
|
||||
created_by_admin_id, started_at_ms, ended_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,'',0,0,?,?,0,?,?)`,
|
||||
experiment.AppCode, experiment.ExperimentID, experiment.PoolID, experiment.Name,
|
||||
experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.TreatmentTrafficPPM, experiment.BucketSalt,
|
||||
experiment.Status, experiment.CreatedByAdminID, experiment.StartedAtMS, nowMS, experiment.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, experiment.ExperimentID, cmd.Overrides, cmd.OperatorAdminID, nowMS); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
experiment.Overrides = cmd.Overrides
|
||||
return experiment, treatment, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
appCode := appcode.Normalize(query.AppCode)
|
||||
where := []string{"app_code = ?"}
|
||||
args := []any{appCode}
|
||||
if poolID := strings.TrimSpace(query.PoolID); poolID != "" {
|
||||
where = append(where, "pool_id = ?")
|
||||
args = append(args, luckyPoolID(poolID))
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(query.Status)); status != "" {
|
||||
where = append(where, "status = ?")
|
||||
args = append(args, status)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT `+luckyExperimentSelectColumns+`
|
||||
FROM lucky_gift_experiments
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY started_at_ms DESC, experiment_id ASC
|
||||
LIMIT 200`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
experiments := make([]domain.Experiment, 0)
|
||||
for rows.Next() {
|
||||
experiment, err := scanLuckyGiftExperiment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
experiments = append(experiments, experiment)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range experiments {
|
||||
overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiments[index].ExperimentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
experiments[index].Overrides = overrides
|
||||
if query.WithStats {
|
||||
stats, err := r.getLuckyGiftExperimentArmStats(ctx, experiments[index])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
experiments[index].ArmStats = stats
|
||||
}
|
||||
}
|
||||
return experiments, nil
|
||||
}
|
||||
|
||||
// UpdateLuckyGiftExperiment 调整放量比例或在 running/paused 之间切换。
|
||||
// completed 实验是已收口的历史事实,不允许任何修改。
|
||||
func (r *Repository) UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
|
||||
}
|
||||
if experiment.Status == domain.ExperimentStatusCompleted {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable")
|
||||
}
|
||||
if cmd.TreatmentTrafficPPM >= 0 {
|
||||
experiment.TreatmentTrafficPPM = cmd.TreatmentTrafficPPM
|
||||
}
|
||||
if cmd.Status != "" {
|
||||
experiment.Status = cmd.Status
|
||||
}
|
||||
experiment.UpdatedAtMS = nowMS
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE lucky_gift_experiments
|
||||
SET treatment_traffic_ppm = ?, status = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND experiment_id = ?`,
|
||||
experiment.TreatmentTrafficPPM, experiment.Status, experiment.UpdatedAtMS,
|
||||
appCode, cmd.ExperimentID,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
experiment.Overrides = overrides
|
||||
return experiment, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
|
||||
}
|
||||
if experiment.Status == domain.ExperimentStatusCompleted {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable")
|
||||
}
|
||||
if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, cmd.ExperimentID, cmd.Upserts, cmd.OperatorAdminID, nowMS); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
for _, userKey := range cmd.Removes {
|
||||
userKey = strings.TrimSpace(userKey)
|
||||
if userKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM lucky_gift_experiment_overrides
|
||||
WHERE app_code = ? AND experiment_id = ? AND user_key = ?`,
|
||||
appCode, cmd.ExperimentID, userKey,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
}
|
||||
// 累计上限在事务内核算:单次 500 的入口校验挡不住多次追加;超限会让 LIMIT 读取截断白名单展示,必须整体拒绝。
|
||||
var totalOverrides int
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM lucky_gift_experiment_overrides
|
||||
WHERE app_code = ? AND experiment_id = ?`,
|
||||
appCode, cmd.ExperimentID,
|
||||
).Scan(&totalOverrides); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
if totalOverrides > domain.MaxExperimentOverrides {
|
||||
return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment overrides exceed the 500 user limit")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE lucky_gift_experiments SET updated_at_ms = ?
|
||||
WHERE app_code = ? AND experiment_id = ?`,
|
||||
nowMS, appCode, cmd.ExperimentID,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
experiment.UpdatedAtMS = nowMS
|
||||
overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
experiment.Overrides = overrides
|
||||
return experiment, nil
|
||||
}
|
||||
|
||||
// EndLuckyGiftExperiment 结束实验:把获胜组钉住的配置快照重发布为新的最新版本,并在同一事务里收口实验。
|
||||
// 重发布保证结束后"最新版本生效"的默认语义恰好收敛到获胜配置,没有先改状态再切版本的中间态。
|
||||
func (r *Repository) EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
// 无锁预读只为拿到不可变的 pool_id:先锁规则行再锁实验行,与常规发布/创建实验保持同一加锁顺序,
|
||||
// 消除 End 与 Publish/Create 并发时的 AB-BA 交叉死锁。状态判断以下方锁读后的行为准。
|
||||
peek, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, false)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
|
||||
}
|
||||
if _, _, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, peek.PoolID, true); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
|
||||
}
|
||||
if experiment.Status == domain.ExperimentStatusCompleted {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is already completed")
|
||||
}
|
||||
winnerVersion := domain.ExperimentArmRuleVersion(experiment, cmd.WinnerArm)
|
||||
winnerConfig, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, tx, appCode, experiment.PoolID, winnerVersion, false)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Internal, "lucky gift experiment winner rule version is missing")
|
||||
}
|
||||
winnerConfig.CreatedByAdminID = cmd.OperatorAdminID
|
||||
winnerConfig.EffectiveFromMS = 0
|
||||
if winnerConfig.StrategyVersion == domain.StrategyDynamicV3 {
|
||||
// 零冷启动策略上线前的遗留版本行可能残留 initial_pool_coins;运行时只做读时清零不回写,
|
||||
// 重发布入口必须同样清零,否则以此类版本为获胜方收口会被发布守卫拒绝,实验无法结束。
|
||||
winnerConfig.InitialPoolCoins = 0
|
||||
}
|
||||
finalConfig, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, winnerConfig, nowMS)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
experiment.Status = domain.ExperimentStatusCompleted
|
||||
experiment.WinnerArm = cmd.WinnerArm
|
||||
experiment.WinnerRuleVersion = winnerVersion
|
||||
experiment.FinalRuleVersion = finalConfig.RuleVersion
|
||||
experiment.EndedAtMS = nowMS
|
||||
experiment.UpdatedAtMS = nowMS
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE lucky_gift_experiments
|
||||
SET status = ?, winner_arm = ?, winner_rule_version = ?, final_rule_version = ?, ended_at_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND experiment_id = ?`,
|
||||
experiment.Status, experiment.WinnerArm, experiment.WinnerRuleVersion, experiment.FinalRuleVersion,
|
||||
experiment.EndedAtMS, experiment.UpdatedAtMS,
|
||||
appCode, cmd.ExperimentID,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
|
||||
if err != nil {
|
||||
return domain.Experiment{}, domain.RuleConfig{}, err
|
||||
}
|
||||
experiment.Overrides = overrides
|
||||
return experiment, finalConfig, nil
|
||||
}
|
||||
|
||||
func upsertLuckyGiftExperimentOverrides(ctx context.Context, tx *sql.Tx, appCode string, experimentID string, overrides []domain.ExperimentOverride, operatorAdminID int64, nowMS int64) error {
|
||||
for _, override := range overrides {
|
||||
userKey := strings.TrimSpace(override.UserKey)
|
||||
if userKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO lucky_gift_experiment_overrides (app_code, experiment_id, user_key, arm, created_by_admin_id, created_at_ms)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE arm = VALUES(arm), created_by_admin_id = VALUES(created_by_admin_id)`,
|
||||
appCode, experimentID, userKey, override.Arm, operatorAdminID, nowMS,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) listLuckyGiftExperimentOverrides(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string) ([]domain.ExperimentOverride, error) {
|
||||
rows, err := q.QueryContext(ctx, `
|
||||
SELECT user_key, arm FROM lucky_gift_experiment_overrides
|
||||
WHERE app_code = ? AND experiment_id = ?
|
||||
ORDER BY user_key ASC
|
||||
LIMIT 1000`, appCode, experimentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
overrides := make([]domain.ExperimentOverride, 0)
|
||||
for rows.Next() {
|
||||
var override domain.ExperimentOverride
|
||||
if err := rows.Scan(&override.UserKey, &override.Arm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overrides = append(overrides, override)
|
||||
}
|
||||
return overrides, rows.Err()
|
||||
}
|
||||
|
||||
// getLuckyGiftExperimentArmStats 按 rule_version 从抽奖事实现算两组聚合。
|
||||
// 只统计内部房间抽奖(lucky_draw_records);外部 App 抽奖走独立审计表,不混入同一口径。
|
||||
func (r *Repository) getLuckyGiftExperimentArmStats(ctx context.Context, experiment domain.Experiment) ([]domain.ExperimentArmStats, error) {
|
||||
statsByVersion := map[int64]*domain.ExperimentArmStats{}
|
||||
arms := []domain.ExperimentArmStats{
|
||||
{Arm: domain.ExperimentArmControl, RuleVersion: experiment.ControlRuleVersion},
|
||||
{Arm: domain.ExperimentArmTreatment, RuleVersion: experiment.TreatmentRuleVersion},
|
||||
}
|
||||
windowSQL := ""
|
||||
args := []any{experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.StartedAtMS}
|
||||
if experiment.Status == domain.ExperimentStatusCompleted && experiment.EndedAtMS > 0 {
|
||||
windowSQL = " AND created_at_ms <= ?"
|
||||
args = append(args, experiment.EndedAtMS)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT rule_version, COUNT(*), COUNT(DISTINCT user_id),
|
||||
COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0)
|
||||
FROM lucky_draw_records
|
||||
WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?) AND created_at_ms >= ?`+windowSQL+`
|
||||
GROUP BY rule_version`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var stats domain.ExperimentArmStats
|
||||
if err := rows.Scan(&stats.RuleVersion, &stats.TotalDraws, &stats.UniqueUsers, &stats.TotalSpentCoins, &stats.TotalRewardCoins); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stats.TotalSpentCoins > 0 {
|
||||
stats.ActualRTPPPM = stats.TotalRewardCoins * luckyPPMScale / stats.TotalSpentCoins
|
||||
}
|
||||
statsByVersion[stats.RuleVersion] = &stats
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strategyRows, err := r.db.QueryContext(ctx, `
|
||||
SELECT rule_version, strategy_version FROM lucky_gift_rule_versions
|
||||
WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?)`,
|
||||
experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer strategyRows.Close()
|
||||
strategyByVersion := map[int64]string{}
|
||||
for strategyRows.Next() {
|
||||
var ruleVersion int64
|
||||
var strategyVersion string
|
||||
if err := strategyRows.Scan(&ruleVersion, &strategyVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strategyByVersion[ruleVersion] = strings.ToLower(strings.TrimSpace(strategyVersion))
|
||||
}
|
||||
if err := strategyRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range arms {
|
||||
if found := statsByVersion[arms[index].RuleVersion]; found != nil {
|
||||
arms[index].TotalDraws = found.TotalDraws
|
||||
arms[index].UniqueUsers = found.UniqueUsers
|
||||
arms[index].TotalSpentCoins = found.TotalSpentCoins
|
||||
arms[index].TotalRewardCoins = found.TotalRewardCoins
|
||||
arms[index].ActualRTPPPM = found.ActualRTPPPM
|
||||
}
|
||||
arms[index].StrategyVersion = strategyByVersion[arms[index].RuleVersion]
|
||||
}
|
||||
return arms, nil
|
||||
}
|
||||
|
||||
func scanLuckyGiftExperiment(scanner luckyScanner) (domain.Experiment, error) {
|
||||
var experiment domain.Experiment
|
||||
if err := scanner.Scan(
|
||||
&experiment.AppCode,
|
||||
&experiment.ExperimentID,
|
||||
&experiment.PoolID,
|
||||
&experiment.Name,
|
||||
&experiment.ControlRuleVersion,
|
||||
&experiment.TreatmentRuleVersion,
|
||||
&experiment.TreatmentTrafficPPM,
|
||||
&experiment.BucketSalt,
|
||||
&experiment.Status,
|
||||
&experiment.WinnerArm,
|
||||
&experiment.WinnerRuleVersion,
|
||||
&experiment.FinalRuleVersion,
|
||||
&experiment.CreatedByAdminID,
|
||||
&experiment.StartedAtMS,
|
||||
&experiment.EndedAtMS,
|
||||
&experiment.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return domain.Experiment{}, err
|
||||
}
|
||||
return experiment, nil
|
||||
}
|
||||
@ -0,0 +1,347 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"hyapp/internal/testutil/mysqlschema"
|
||||
"hyapp/pkg/appcode"
|
||||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||||
)
|
||||
|
||||
func experimentTestFixedRule(poolID string, targetRTPPPM int64) domain.RuleConfig {
|
||||
return domain.RuleConfig{
|
||||
PoolID: poolID,
|
||||
Enabled: true,
|
||||
StrategyVersion: domain.StrategyFixedV2,
|
||||
TargetRTPPPM: targetRTPPPM,
|
||||
PoolRatePPM: 980_000,
|
||||
SettlementWindowWager: 1_000_000,
|
||||
ControlBandPPM: 10_000,
|
||||
GiftPriceReference: 100,
|
||||
NoviceMaxEquivalentDraws: 2_000,
|
||||
NormalMaxEquivalentDraws: 20_000,
|
||||
Stages: []domain.RuleStage{
|
||||
{Stage: domain.StageNovice, Tiers: []domain.RuleTier{
|
||||
{Stage: domain.StageNovice, TierID: "novice_1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1_000_000, Enabled: true},
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:AB 实验完整生命周期——创建时原子发布 treatment、分流按白名单/哈希钉住版本、
|
||||
// 实验期常规发布被拒绝、暂停回落 control、结束时获胜配置重发布为最新版本并恢复默认语义。
|
||||
func TestLuckyGiftExperimentLifecycle(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
repo := &Repository{db: schema.DB}
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
|
||||
control := experimentTestFixedRule("super_lucky", 950_000)
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("publish control config: %v", err)
|
||||
}
|
||||
|
||||
treatment := experimentTestFixedRule("super_lucky", 960_000)
|
||||
experiment, published, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "super_lucky v2 rtp+1",
|
||||
TreatmentConfig: treatment,
|
||||
TreatmentTrafficPPM: 0,
|
||||
OperatorAdminID: 7,
|
||||
Overrides: []domain.ExperimentOverride{{UserKey: "1001", Arm: domain.ExperimentArmTreatment}},
|
||||
}, 1_700_000_100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("create experiment: %v", err)
|
||||
}
|
||||
if experiment.ControlRuleVersion != 1 || experiment.TreatmentRuleVersion != 2 || published.RuleVersion != 2 {
|
||||
t.Fatalf("experiment pinned versions control=%d treatment=%d published=%d, want 1/2/2",
|
||||
experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, published.RuleVersion)
|
||||
}
|
||||
|
||||
// 白名单用户解析到 treatment v2;普通用户在 0% 放量下解析到 control v1。
|
||||
resolved, exists, err := repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "1001", false)
|
||||
if err != nil || !exists || resolved.RuleVersion != 2 {
|
||||
t.Fatalf("override user resolved version=%d exists=%v err=%v, want treatment v2", resolved.RuleVersion, exists, err)
|
||||
}
|
||||
resolved, exists, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false)
|
||||
if err != nil || !exists || resolved.RuleVersion != 1 {
|
||||
t.Fatalf("hash user resolved version=%d exists=%v err=%v, want control v1 at 0%% traffic", resolved.RuleVersion, exists, err)
|
||||
}
|
||||
|
||||
// 实验期间常规发布必须被拒绝,避免运营发布一个静默无效的版本。
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_200_000); err == nil {
|
||||
t.Fatal("regular publish should be rejected while an experiment is active")
|
||||
}
|
||||
// 同奖池不允许并发第二个实验。
|
||||
if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "dup", TreatmentConfig: treatment, OperatorAdminID: 7,
|
||||
}, 1_700_000_200_000); err == nil {
|
||||
t.Fatal("second active experiment on the same pool should be rejected")
|
||||
}
|
||||
|
||||
// 放量 100% 后普通用户进入 treatment;确定性哈希保证任何用户都命中 bucket < 1000000。
|
||||
if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{
|
||||
ExperimentID: experiment.ExperimentID,
|
||||
TreatmentTrafficPPM: 1_000_000,
|
||||
}, 1_700_000_300_000); err != nil {
|
||||
t.Fatalf("update experiment traffic: %v", err)
|
||||
}
|
||||
resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false)
|
||||
if err != nil || resolved.RuleVersion != 2 {
|
||||
t.Fatalf("full ramp resolved version=%d err=%v, want treatment v2", resolved.RuleVersion, err)
|
||||
}
|
||||
|
||||
// 暂停后包括白名单在内所有用户回落 control。
|
||||
if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{
|
||||
ExperimentID: experiment.ExperimentID,
|
||||
TreatmentTrafficPPM: -1,
|
||||
Status: domain.ExperimentStatusPaused,
|
||||
}, 1_700_000_400_000); err != nil {
|
||||
t.Fatalf("pause experiment: %v", err)
|
||||
}
|
||||
for _, userKey := range []string{"1001", "2002"} {
|
||||
resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", userKey, false)
|
||||
if err != nil || resolved.RuleVersion != 1 {
|
||||
t.Fatalf("paused experiment user %s resolved version=%d err=%v, want control v1", userKey, resolved.RuleVersion, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 结束实验:treatment 获胜 → 快照重发布为 v3,实验收口,默认"最新版本生效"恢复。
|
||||
ended, finalConfig, err := repo.EndLuckyGiftExperiment(ctx, domain.EndExperimentCommand{
|
||||
ExperimentID: experiment.ExperimentID,
|
||||
WinnerArm: domain.ExperimentArmTreatment,
|
||||
OperatorAdminID: 7,
|
||||
}, 1_700_000_500_000)
|
||||
if err != nil {
|
||||
t.Fatalf("end experiment: %v", err)
|
||||
}
|
||||
if ended.Status != domain.ExperimentStatusCompleted || ended.WinnerRuleVersion != 2 || ended.FinalRuleVersion != 3 || finalConfig.RuleVersion != 3 {
|
||||
t.Fatalf("ended experiment = %+v final=%d, want completed winner v2 republished as v3", ended, finalConfig.RuleVersion)
|
||||
}
|
||||
if finalConfig.TargetRTPPPM != 960_000 {
|
||||
t.Fatalf("final config target rtp = %d, want treatment content 960000", finalConfig.TargetRTPPPM)
|
||||
}
|
||||
resolved, _, err = repo.resolveLuckyGiftRuleConfigForUser(ctx, schema.DB, "lalu", "super_lucky", "2002", false)
|
||||
if err != nil || resolved.RuleVersion != 3 {
|
||||
t.Fatalf("post-experiment resolved version=%d err=%v, want latest v3", resolved.RuleVersion, err)
|
||||
}
|
||||
// 实验收口后常规发布恢复可用。
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, control, 1_700_000_600_000); err != nil {
|
||||
t.Fatalf("regular publish after experiment completion: %v", err)
|
||||
}
|
||||
// 结束后的实验不可再修改。
|
||||
if _, err := repo.UpdateLuckyGiftExperiment(ctx, domain.UpdateExperimentCommand{
|
||||
ExperimentID: experiment.ExperimentID,
|
||||
TreatmentTrafficPPM: 1,
|
||||
}, 1_700_000_700_000); err == nil {
|
||||
t.Fatal("completed experiment must be immutable")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:实验列表带分组统计——抽奖事实按 rule_version 聚合到对应组,窗口从实验开始时间起算。
|
||||
func TestLuckyGiftExperimentArmStats(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
repo := &Repository{db: schema.DB}
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("lucky", 950_000), 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("publish control config: %v", err)
|
||||
}
|
||||
experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "stats",
|
||||
TreatmentConfig: experimentTestFixedRule("lucky", 960_000),
|
||||
TreatmentTrafficPPM: 500_000,
|
||||
OperatorAdminID: 7,
|
||||
}, 1_700_000_100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("create experiment: %v", err)
|
||||
}
|
||||
seedDraw := func(drawID, commandID string, ruleVersion, userID, spent, reward, createdAtMS int64) {
|
||||
t.Helper()
|
||||
if _, err := schema.DB.Exec(`
|
||||
INSERT INTO lucky_draw_records (
|
||||
app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id,
|
||||
rule_version, experience_pool, selected_tier_id, coin_spent,
|
||||
base_reward_coins, effective_reward_coins, reward_status, rtp_window_index, gift_rtp_window_index,
|
||||
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json,
|
||||
paid_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES ('lalu', ?, ?, ?, 'd', 'room', 'anchor', 'lucky', 'lucky', ?, 'novice', 't', ?, ?, ?, 'granted', 1, 1, '{}', '{}', '{}', ?, ?, ?)`,
|
||||
drawID, commandID, userID, ruleVersion, spent, reward, reward, createdAtMS, createdAtMS, createdAtMS,
|
||||
); err != nil {
|
||||
t.Fatalf("seed draw %s: %v", drawID, err)
|
||||
}
|
||||
}
|
||||
// 实验开始前的旧版本抽奖不计入统计窗口。
|
||||
seedDraw("d0", "c0", 1, 1, 1_000, 900, 1_600_000_000_000)
|
||||
seedDraw("d1", "c1", 1, 1, 1_000, 900, 1_700_000_200_000)
|
||||
seedDraw("d2", "c2", 1, 2, 1_000, 800, 1_700_000_200_001)
|
||||
seedDraw("d3", "c3", 2, 3, 2_000, 2_200, 1_700_000_200_002)
|
||||
|
||||
experiments, err := repo.ListLuckyGiftExperiments(ctx, domain.ExperimentQuery{AppCode: "lalu", PoolID: "lucky", WithStats: true})
|
||||
if err != nil {
|
||||
t.Fatalf("list experiments with stats: %v", err)
|
||||
}
|
||||
if len(experiments) != 1 || len(experiments[0].ArmStats) != 2 {
|
||||
t.Fatalf("experiments=%d armStats=%v, want 1 experiment with 2 arms", len(experiments), experiments)
|
||||
}
|
||||
statsByArm := map[string]domain.ExperimentArmStats{}
|
||||
for _, stats := range experiments[0].ArmStats {
|
||||
statsByArm[stats.Arm] = stats
|
||||
}
|
||||
controlStats := statsByArm[domain.ExperimentArmControl]
|
||||
if controlStats.TotalDraws != 2 || controlStats.UniqueUsers != 2 || controlStats.TotalSpentCoins != 2_000 || controlStats.TotalRewardCoins != 1_700 {
|
||||
t.Fatalf("control stats = %+v, want 2 draws / 2 users / 2000 spent / 1700 reward", controlStats)
|
||||
}
|
||||
if controlStats.ActualRTPPPM != 850_000 {
|
||||
t.Fatalf("control rtp = %d, want 850000 ppm", controlStats.ActualRTPPPM)
|
||||
}
|
||||
treatmentStats := statsByArm[domain.ExperimentArmTreatment]
|
||||
if treatmentStats.TotalDraws != 1 || treatmentStats.TotalSpentCoins != 2_000 || treatmentStats.ActualRTPPPM != 1_100_000 {
|
||||
t.Fatalf("treatment stats = %+v, want 1 draw / 2000 spent / 1.1 rtp", treatmentStats)
|
||||
}
|
||||
if controlStats.StrategyVersion != domain.StrategyFixedV2 || treatmentStats.StrategyVersion != domain.StrategyFixedV2 {
|
||||
t.Fatalf("arm strategy versions = %q/%q, want fixed_v2", controlStats.StrategyVersion, treatmentStats.StrategyVersion)
|
||||
}
|
||||
_ = experiment
|
||||
}
|
||||
|
||||
// 回归:两组必须同策略族。room saga 在 Check 固化 strategy_version 且恢复不重新 Check,
|
||||
// 混合策略实验的分组漂移会把已扣费命令打进 dynamic_v3 的 fail-close,必须在创建入口拒绝。
|
||||
func TestCreateExperimentRejectsMixedStrategyArms(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
repo := &Repository{db: schema.DB}
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("lucky", 950_000), 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("publish fixed control: %v", err)
|
||||
}
|
||||
dynamicTreatment := dynamicMySQLTestRule("lucky")
|
||||
if _, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "mixed", TreatmentConfig: dynamicTreatment, OperatorAdminID: 7,
|
||||
}, 1_700_000_100_000); err == nil {
|
||||
t.Fatal("mixed-strategy experiment must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:零冷启动守卫上线前的遗留 dynamic_v3 版本行残留 initial_pool_coins;
|
||||
// 以它为获胜方收口时重发布入口必须清零,否则实验永远无法以 control 结束。
|
||||
func TestEndExperimentRepublishesLegacyDirtyDynamicControl(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
repo := &Repository{db: schema.DB}
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule("dirty"), 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("publish dynamic control: %v", err)
|
||||
}
|
||||
// 模拟迁移前发布的脏行:配置里残留 initial_pool_coins,运行时只做读时清零不回写。
|
||||
if _, err := schema.DB.Exec(`
|
||||
UPDATE lucky_gift_rule_versions SET initial_pool_coins = 1000000
|
||||
WHERE app_code = 'lalu' AND pool_id = 'dirty' AND rule_version = 1`); err != nil {
|
||||
t.Fatalf("seed dirty initial_pool_coins: %v", err)
|
||||
}
|
||||
experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "dirty-control", TreatmentConfig: dynamicMySQLTestRule("dirty"), OperatorAdminID: 7,
|
||||
}, 1_700_000_100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("create dynamic experiment: %v", err)
|
||||
}
|
||||
ended, finalConfig, err := repo.EndLuckyGiftExperiment(ctx, domain.EndExperimentCommand{
|
||||
ExperimentID: experiment.ExperimentID, WinnerArm: domain.ExperimentArmControl, OperatorAdminID: 7,
|
||||
}, 1_700_000_200_000)
|
||||
if err != nil {
|
||||
t.Fatalf("end experiment with dirty dynamic control as winner: %v", err)
|
||||
}
|
||||
if ended.Status != domain.ExperimentStatusCompleted || finalConfig.InitialPoolCoins != 0 {
|
||||
t.Fatalf("ended=%+v final initial_pool_coins=%d, want completed with zeroed coins", ended, finalConfig.InitialPoolCoins)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:白名单累计上限在事务内核算,多次追加不能绕过单次 500 的入口校验。
|
||||
func TestSetExperimentOverridesEnforcesCumulativeCap(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
repo := &Repository{db: schema.DB}
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, experimentTestFixedRule("cap", 950_000), 1_700_000_000_000); err != nil {
|
||||
t.Fatalf("publish control: %v", err)
|
||||
}
|
||||
experiment, _, err := repo.CreateLuckyGiftExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: "cap", TreatmentConfig: experimentTestFixedRule("cap", 960_000), OperatorAdminID: 7,
|
||||
}, 1_700_000_100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("create experiment: %v", err)
|
||||
}
|
||||
batch := func(from, to int) []domain.ExperimentOverride {
|
||||
out := make([]domain.ExperimentOverride, 0, to-from)
|
||||
for i := from; i < to; i++ {
|
||||
out = append(out, domain.ExperimentOverride{UserKey: fmt.Sprintf("%d", i), Arm: domain.ExperimentArmTreatment})
|
||||
}
|
||||
return out
|
||||
}
|
||||
if _, err := repo.SetLuckyGiftExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{
|
||||
ExperimentID: experiment.ExperimentID, Upserts: batch(0, 500), OperatorAdminID: 7,
|
||||
}, 1_700_000_200_000); err != nil {
|
||||
t.Fatalf("first 500 overrides should be accepted: %v", err)
|
||||
}
|
||||
if _, err := repo.SetLuckyGiftExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{
|
||||
ExperimentID: experiment.ExperimentID, Upserts: batch(500, 501), OperatorAdminID: 7,
|
||||
}, 1_700_000_300_000); err == nil {
|
||||
t.Fatal("501st override must be rejected by the cumulative cap")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:006 迁移在全新库上可重复执行,生成列唯一键允许多个 completed 实验但只允许一个未完成实验。
|
||||
func TestExperimentMigrationIsIdempotentAndEnforcesSingleActive(t *testing.T) {
|
||||
caller := mysqlschema.CallerFile(t, 1)
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
|
||||
DatabasePrefix: "hy_lucky_gift",
|
||||
})
|
||||
migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "006_ab_experiments.sql")
|
||||
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
|
||||
|
||||
insert := func(id, status string) error {
|
||||
_, err := schema.DB.Exec(`
|
||||
INSERT INTO lucky_gift_experiments (
|
||||
app_code, experiment_id, pool_id, name, control_rule_version, treatment_rule_version,
|
||||
treatment_traffic_ppm, bucket_salt, status, started_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES ('lalu', ?, 'lucky', 'n', 1, 2, 0, 's', ?, 1, 1, 1)`, id, status)
|
||||
return err
|
||||
}
|
||||
if err := insert("e1", "completed"); err != nil {
|
||||
t.Fatalf("insert completed experiment: %v", err)
|
||||
}
|
||||
if err := insert("e2", "completed"); err != nil {
|
||||
t.Fatalf("second completed experiment should not hit the active unique key: %v", err)
|
||||
}
|
||||
if err := insert("e3", "running"); err != nil {
|
||||
t.Fatalf("insert running experiment: %v", err)
|
||||
}
|
||||
if err := insert("e4", "paused"); err == nil {
|
||||
t.Fatal("second unfinished experiment must violate uk_lucky_gift_experiments_active_pool")
|
||||
}
|
||||
}
|
||||
@ -151,7 +151,8 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
|
||||
return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
poolID := luckyPoolID(cmd.PoolID)
|
||||
ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false)
|
||||
// Check 与 Draw 必须解析到同一份配置:room saga 会固化 Check 返回的 strategy_version,恢复阶段不重新 Check。
|
||||
ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, r.db, appcode.FromContext(ctx), poolID, domain.ExperimentUserKey(cmd.UserID), false)
|
||||
if err != nil {
|
||||
return domain.CheckResult{}, err
|
||||
}
|
||||
@ -315,7 +316,8 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
|
||||
poolID := luckyPoolID(cmd.PoolID)
|
||||
|
||||
// 规则配置是本批抽奖的不可变快照;同一批内不允许因为后台改配置导致前后两抽规则不同。
|
||||
ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
|
||||
// 实验期按用户钉住组内版本,同一用户在实验生命周期内的解析结果确定。
|
||||
ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -1095,7 +1097,7 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
|
||||
ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true)
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
|
||||
@ -37,6 +37,29 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 先执行发布(首个语句会锁住最新版本行),再做实验检查,保持与 CreateLuckyGiftExperiment
|
||||
// 一致的"规则行 → 实验行"加锁顺序,避免两个入口交叉加锁互相死锁;检查失败整个事务回滚。
|
||||
published, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, config, nowMS)
|
||||
if err != nil {
|
||||
return domain.RuleConfig{}, err
|
||||
}
|
||||
// 实验期间两组都钉住既有版本,常规发布的新版本不会生效,只会在实验结束时被获胜版本覆盖;
|
||||
// 与其让运营发布一个静默无效的版本,不如在入口明确拒绝。实验自身的发布走 publishLuckyGiftRuleConfigTx。
|
||||
appCode := appcode.FromContext(ctx)
|
||||
poolID := luckyPoolID(config.PoolID)
|
||||
if _, active, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
|
||||
return domain.RuleConfig{}, err
|
||||
} else if active {
|
||||
return domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool has an active experiment; end the experiment before publishing a new config")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.RuleConfig{}, err
|
||||
}
|
||||
return published, nil
|
||||
}
|
||||
|
||||
// publishLuckyGiftRuleConfigTx 在调用方事务里追加一个不可变版本;创建/结束实验借助它把发布和实验状态变更收进同一个事务。
|
||||
func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.Tx, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
|
||||
appCode := appcode.FromContext(ctx)
|
||||
config.AppCode = appCode
|
||||
config.PoolID = luckyPoolID(config.PoolID)
|
||||
@ -140,9 +163,6 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
|
||||
return domain.RuleConfig{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.RuleConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@ -201,6 +221,23 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
|
||||
return configs, rows.Err()
|
||||
}
|
||||
|
||||
const luckyRuleConfigSelectSQL = `
|
||||
SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
|
||||
profit_rate_ppm, anchor_rate_ppm,
|
||||
settlement_window_wager, control_band_ppm, gift_price_reference,
|
||||
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
||||
effective_from_ms, created_by_admin_id, created_at_ms,
|
||||
initial_pool_coins, loss_streak_guarantee,
|
||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
||||
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
|
||||
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||
FROM lucky_gift_rule_versions
|
||||
WHERE app_code = ? AND pool_id = ?`
|
||||
|
||||
func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.RuleConfig, bool, error) {
|
||||
if poolID == "" {
|
||||
return domain.RuleConfig{}, false, nil
|
||||
@ -209,26 +246,31 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
|
||||
if forUpdate {
|
||||
lockSQL = " FOR UPDATE"
|
||||
}
|
||||
row := q.QueryRowContext(ctx, `
|
||||
SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
|
||||
profit_rate_ppm, anchor_rate_ppm,
|
||||
settlement_window_wager, control_band_ppm, gift_price_reference,
|
||||
novice_max_equivalent_draws, normal_max_equivalent_draws,
|
||||
effective_from_ms, created_by_admin_id, created_at_ms,
|
||||
initial_pool_coins, loss_streak_guarantee,
|
||||
low_watermark_coins, low_water_nonzero_factor_ppm,
|
||||
high_watermark_coins, high_water_nonzero_factor_ppm,
|
||||
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
|
||||
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
|
||||
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
|
||||
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
|
||||
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
|
||||
FROM lucky_gift_rule_versions
|
||||
WHERE app_code = ? AND pool_id = ?
|
||||
row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+`
|
||||
ORDER BY rule_version DESC
|
||||
LIMIT 1`+lockSQL,
|
||||
appCode, poolID,
|
||||
)
|
||||
return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row)
|
||||
}
|
||||
|
||||
// getLuckyGiftRuleConfigVersion 读取实验钉住的指定版本快照;版本行不可变,语义与最新版本读取一致。
|
||||
func (r *Repository) getLuckyGiftRuleConfigVersion(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64, forUpdate bool) (domain.RuleConfig, bool, error) {
|
||||
if poolID == "" || ruleVersion <= 0 {
|
||||
return domain.RuleConfig{}, false, nil
|
||||
}
|
||||
lockSQL := ""
|
||||
if forUpdate {
|
||||
lockSQL = " FOR UPDATE"
|
||||
}
|
||||
row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+`
|
||||
AND rule_version = ?`+lockSQL,
|
||||
appCode, poolID, ruleVersion,
|
||||
)
|
||||
return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row)
|
||||
}
|
||||
|
||||
func (r *Repository) finishLuckyGiftRuleConfigRow(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, row *sql.Row) (domain.RuleConfig, bool, error) {
|
||||
config, err := scanLuckyGiftRuleConfig(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@ -268,6 +268,156 @@ func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, r
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminLuckyGiftServer) CreateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.CreateLuckyGiftExperimentRequest) (*luckygiftv1.CreateLuckyGiftExperimentResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
// 实验创建会发布新配置版本并改变线上分流;缺 meta 不能静默落到默认租户。
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
experiment, treatment, err := s.svc.CreateExperiment(ctx, domain.CreateExperimentCommand{
|
||||
Name: req.GetName(),
|
||||
TreatmentConfig: luckyRuleConfigFromProto(req.GetTreatmentConfig()),
|
||||
TreatmentTrafficPPM: req.GetTreatmentTrafficPpm(),
|
||||
OperatorAdminID: req.GetOperatorAdminId(),
|
||||
Overrides: luckyExperimentOverridesFromProto(req.GetOverrides()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &luckygiftv1.CreateLuckyGiftExperimentResponse{
|
||||
Experiment: luckyExperimentToProto(experiment),
|
||||
TreatmentConfig: luckyRuleConfigToProto(treatment),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminLuckyGiftServer) ListLuckyGiftExperiments(ctx context.Context, req *luckygiftv1.ListLuckyGiftExperimentsRequest) (*luckygiftv1.ListLuckyGiftExperimentsResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
// 实验查询按单 app 组织;空 app_code 不能像兼容查询一样静默落到默认租户。
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
experiments, err := s.svc.ListExperiments(ctx, domain.ExperimentQuery{
|
||||
AppCode: req.GetMeta().GetAppCode(),
|
||||
PoolID: req.GetPoolId(),
|
||||
Status: req.GetStatus(),
|
||||
WithStats: req.GetWithStats(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &luckygiftv1.ListLuckyGiftExperimentsResponse{Experiments: make([]*luckygiftv1.LuckyGiftExperiment, 0, len(experiments))}
|
||||
for _, experiment := range experiments {
|
||||
resp.Experiments = append(resp.Experiments, luckyExperimentToProto(experiment))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *AdminLuckyGiftServer) UpdateLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.UpdateLuckyGiftExperimentRequest) (*luckygiftv1.UpdateLuckyGiftExperimentResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
// 流量字段用 proto3 optional 的存在性表达"不修改";缺省 0 绝不能被解释成放量清零。
|
||||
trafficPPM := int64(-1)
|
||||
if req.TreatmentTrafficPpm != nil {
|
||||
trafficPPM = req.GetTreatmentTrafficPpm()
|
||||
}
|
||||
experiment, err := s.svc.UpdateExperiment(ctx, domain.UpdateExperimentCommand{
|
||||
ExperimentID: req.GetExperimentId(),
|
||||
TreatmentTrafficPPM: trafficPPM,
|
||||
Status: req.GetStatus(),
|
||||
OperatorAdminID: req.GetOperatorAdminId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &luckygiftv1.UpdateLuckyGiftExperimentResponse{Experiment: luckyExperimentToProto(experiment)}, nil
|
||||
}
|
||||
|
||||
func (s *AdminLuckyGiftServer) SetLuckyGiftExperimentOverrides(ctx context.Context, req *luckygiftv1.SetLuckyGiftExperimentOverridesRequest) (*luckygiftv1.SetLuckyGiftExperimentOverridesResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
experiment, err := s.svc.SetExperimentOverrides(ctx, domain.SetExperimentOverridesCommand{
|
||||
ExperimentID: req.GetExperimentId(),
|
||||
Upserts: luckyExperimentOverridesFromProto(req.GetUpserts()),
|
||||
Removes: req.GetRemoves(),
|
||||
OperatorAdminID: req.GetOperatorAdminId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &luckygiftv1.SetLuckyGiftExperimentOverridesResponse{Experiment: luckyExperimentToProto(experiment)}, nil
|
||||
}
|
||||
|
||||
func (s *AdminLuckyGiftServer) EndLuckyGiftExperiment(ctx context.Context, req *luckygiftv1.EndLuckyGiftExperimentRequest) (*luckygiftv1.EndLuckyGiftExperimentResponse, error) {
|
||||
if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift experiment app_code is required"))
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
experiment, finalConfig, err := s.svc.EndExperiment(ctx, domain.EndExperimentCommand{
|
||||
ExperimentID: req.GetExperimentId(),
|
||||
WinnerArm: req.GetWinnerArm(),
|
||||
OperatorAdminID: req.GetOperatorAdminId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &luckygiftv1.EndLuckyGiftExperimentResponse{
|
||||
Experiment: luckyExperimentToProto(experiment),
|
||||
FinalConfig: luckyRuleConfigToProto(finalConfig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func luckyExperimentOverridesFromProto(overrides []*luckygiftv1.LuckyGiftExperimentOverride) []domain.ExperimentOverride {
|
||||
out := make([]domain.ExperimentOverride, 0, len(overrides))
|
||||
for _, override := range overrides {
|
||||
if override == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.ExperimentOverride{UserKey: override.GetUserKey(), Arm: override.GetArm()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func luckyExperimentToProto(experiment domain.Experiment) *luckygiftv1.LuckyGiftExperiment {
|
||||
out := &luckygiftv1.LuckyGiftExperiment{
|
||||
AppCode: experiment.AppCode,
|
||||
PoolId: experiment.PoolID,
|
||||
ExperimentId: experiment.ExperimentID,
|
||||
Name: experiment.Name,
|
||||
ControlRuleVersion: experiment.ControlRuleVersion,
|
||||
TreatmentRuleVersion: experiment.TreatmentRuleVersion,
|
||||
TreatmentTrafficPpm: experiment.TreatmentTrafficPPM,
|
||||
BucketSalt: experiment.BucketSalt,
|
||||
Status: experiment.Status,
|
||||
WinnerArm: experiment.WinnerArm,
|
||||
WinnerRuleVersion: experiment.WinnerRuleVersion,
|
||||
FinalRuleVersion: experiment.FinalRuleVersion,
|
||||
CreatedByAdminId: experiment.CreatedByAdminID,
|
||||
StartedAtMs: experiment.StartedAtMS,
|
||||
EndedAtMs: experiment.EndedAtMS,
|
||||
UpdatedAtMs: experiment.UpdatedAtMS,
|
||||
}
|
||||
for _, override := range experiment.Overrides {
|
||||
out.Overrides = append(out.Overrides, &luckygiftv1.LuckyGiftExperimentOverride{UserKey: override.UserKey, Arm: override.Arm})
|
||||
}
|
||||
for _, stats := range experiment.ArmStats {
|
||||
out.ArmStats = append(out.ArmStats, &luckygiftv1.LuckyGiftExperimentArmStats{
|
||||
Arm: stats.Arm,
|
||||
RuleVersion: stats.RuleVersion,
|
||||
StrategyVersion: stats.StrategyVersion,
|
||||
TotalDraws: stats.TotalDraws,
|
||||
UniqueUsers: stats.UniqueUsers,
|
||||
TotalSpentCoins: stats.TotalSpentCoins,
|
||||
TotalRewardCoins: stats.TotalRewardCoins,
|
||||
ActualRtpPpm: stats.ActualRTPPPM,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig {
|
||||
if config == nil {
|
||||
return domain.RuleConfig{}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user