zeeone 接口
This commit is contained in:
parent
8dc76890a6
commit
4af7844d1b
File diff suppressed because it is too large
Load Diff
@ -531,6 +531,122 @@ message ListRegistrationRewardClaimsResponse {
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardTier 是首冲奖励的一个金币充值档位;max_coin_amount=0 表示无上限。
|
||||
message FirstRechargeRewardTier {
|
||||
int64 tier_id = 1;
|
||||
string tier_code = 2;
|
||||
string tier_name = 3;
|
||||
int64 min_coin_amount = 4;
|
||||
int64 max_coin_amount = 5;
|
||||
int64 resource_group_id = 6;
|
||||
string status = 7;
|
||||
int32 sort_order = 8;
|
||||
int64 created_at_ms = 9;
|
||||
int64 updated_at_ms = 10;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardConfig 是当前 App 的首冲奖励配置;档位内容在 activity-service 内保持版本一致。
|
||||
message FirstRechargeRewardConfig {
|
||||
string app_code = 1;
|
||||
bool enabled = 2;
|
||||
repeated FirstRechargeRewardTier tiers = 3;
|
||||
int64 updated_by_admin_id = 4;
|
||||
int64 created_at_ms = 5;
|
||||
int64 updated_at_ms = 6;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardClaim 是某个用户首笔充值触发后的发奖事实;user_id 唯一保证一生只处理一次。
|
||||
message FirstRechargeRewardClaim {
|
||||
string claim_id = 1;
|
||||
string event_id = 2;
|
||||
string transaction_id = 3;
|
||||
string command_id = 4;
|
||||
int64 user_id = 5;
|
||||
int64 tier_id = 6;
|
||||
string tier_code = 7;
|
||||
int64 resource_group_id = 8;
|
||||
int64 recharge_coin_amount = 9;
|
||||
int64 recharge_sequence = 10;
|
||||
string recharge_type = 11;
|
||||
string status = 12;
|
||||
string wallet_command_id = 13;
|
||||
string wallet_grant_id = 14;
|
||||
string failure_reason = 15;
|
||||
int64 recharged_at_ms = 16;
|
||||
int64 granted_at_ms = 17;
|
||||
int64 created_at_ms = 18;
|
||||
int64 updated_at_ms = 19;
|
||||
}
|
||||
|
||||
// FirstRechargeRewardStatus 是 App 首冲奖励面板可直接展示的当前状态。
|
||||
message FirstRechargeRewardStatus {
|
||||
bool enabled = 1;
|
||||
repeated FirstRechargeRewardTier tiers = 2;
|
||||
bool rewarded = 3;
|
||||
FirstRechargeRewardClaim claim = 4;
|
||||
int64 server_time_ms = 5;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardStatusRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardStatusResponse {
|
||||
FirstRechargeRewardStatus status = 1;
|
||||
}
|
||||
|
||||
// ConsumeFirstRechargeRewardRequest 是 wallet 充值事实消费入口;只处理 recharge_sequence=1 的成功充值。
|
||||
message ConsumeFirstRechargeRewardRequest {
|
||||
RequestMeta meta = 1;
|
||||
string event_id = 2;
|
||||
string transaction_id = 3;
|
||||
string command_id = 4;
|
||||
int64 user_id = 5;
|
||||
int64 recharge_coin_amount = 6;
|
||||
int64 recharge_sequence = 7;
|
||||
string recharge_type = 8;
|
||||
int64 occurred_at_ms = 9;
|
||||
}
|
||||
|
||||
message ConsumeFirstRechargeRewardResponse {
|
||||
FirstRechargeRewardClaim claim = 1;
|
||||
bool consumed = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message GetFirstRechargeRewardConfigResponse {
|
||||
FirstRechargeRewardConfig config = 1;
|
||||
}
|
||||
|
||||
message UpdateFirstRechargeRewardConfigRequest {
|
||||
RequestMeta meta = 1;
|
||||
bool enabled = 2;
|
||||
repeated FirstRechargeRewardTier tiers = 3;
|
||||
int64 operator_admin_id = 4;
|
||||
}
|
||||
|
||||
message UpdateFirstRechargeRewardConfigResponse {
|
||||
FirstRechargeRewardConfig config = 1;
|
||||
}
|
||||
|
||||
message ListFirstRechargeRewardClaimsRequest {
|
||||
RequestMeta meta = 1;
|
||||
string status = 2;
|
||||
int64 user_id = 3;
|
||||
int32 page = 4;
|
||||
int32 page_size = 5;
|
||||
}
|
||||
|
||||
message ListFirstRechargeRewardClaimsResponse {
|
||||
repeated FirstRechargeRewardClaim claims = 1;
|
||||
int64 total = 2;
|
||||
}
|
||||
|
||||
// SevenDayCheckInReward 是七日签到配置中某一天的资源组奖励。
|
||||
message SevenDayCheckInReward {
|
||||
int32 day_index = 1;
|
||||
@ -1243,6 +1359,19 @@ service AdminRegistrationRewardService {
|
||||
rpc ListRegistrationRewardClaims(ListRegistrationRewardClaimsRequest) returns (ListRegistrationRewardClaimsResponse);
|
||||
}
|
||||
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
service FirstRechargeRewardService {
|
||||
rpc GetFirstRechargeRewardStatus(GetFirstRechargeRewardStatusRequest) returns (GetFirstRechargeRewardStatusResponse);
|
||||
rpc ConsumeFirstRechargeReward(ConsumeFirstRechargeRewardRequest) returns (ConsumeFirstRechargeRewardResponse);
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
service AdminFirstRechargeRewardService {
|
||||
rpc GetFirstRechargeRewardConfig(GetFirstRechargeRewardConfigRequest) returns (GetFirstRechargeRewardConfigResponse);
|
||||
rpc UpdateFirstRechargeRewardConfig(UpdateFirstRechargeRewardConfigRequest) returns (UpdateFirstRechargeRewardConfigResponse);
|
||||
rpc ListFirstRechargeRewardClaims(ListFirstRechargeRewardClaimsRequest) returns (ListFirstRechargeRewardClaimsResponse);
|
||||
}
|
||||
|
||||
// SevenDayCheckInService 拥有 App 七日签到查询和签到命令。
|
||||
service SevenDayCheckInService {
|
||||
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc (unknown)
|
||||
// - protoc v4.25.3
|
||||
// source: proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
@ -2319,6 +2319,334 @@ var AdminRegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/GetFirstRechargeRewardStatus"
|
||||
FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName = "/hyapp.activity.v1.FirstRechargeRewardService/ConsumeFirstRechargeReward"
|
||||
)
|
||||
|
||||
// FirstRechargeRewardServiceClient is the client API for FirstRechargeRewardService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
type FirstRechargeRewardServiceClient interface {
|
||||
GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error)
|
||||
ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error)
|
||||
}
|
||||
|
||||
type firstRechargeRewardServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) FirstRechargeRewardServiceClient {
|
||||
return &firstRechargeRewardServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *firstRechargeRewardServiceClient) GetFirstRechargeRewardStatus(ctx context.Context, in *GetFirstRechargeRewardStatusRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetFirstRechargeRewardStatusResponse)
|
||||
err := c.cc.Invoke(ctx, FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *firstRechargeRewardServiceClient) ConsumeFirstRechargeReward(ctx context.Context, in *ConsumeFirstRechargeRewardRequest, opts ...grpc.CallOption) (*ConsumeFirstRechargeRewardResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConsumeFirstRechargeRewardResponse)
|
||||
err := c.cc.Invoke(ctx, FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FirstRechargeRewardServiceServer is the server API for FirstRechargeRewardService service.
|
||||
// All implementations must embed UnimplementedFirstRechargeRewardServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// FirstRechargeRewardService 拥有 App 首冲奖励查询和 wallet 充值事实消费入口。
|
||||
type FirstRechargeRewardServiceServer interface {
|
||||
GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error)
|
||||
ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error)
|
||||
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedFirstRechargeRewardServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedFirstRechargeRewardServiceServer struct{}
|
||||
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) GetFirstRechargeRewardStatus(context.Context, *GetFirstRechargeRewardStatusRequest) (*GetFirstRechargeRewardStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardStatus not implemented")
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) ConsumeFirstRechargeReward(context.Context, *ConsumeFirstRechargeRewardRequest) (*ConsumeFirstRechargeRewardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ConsumeFirstRechargeReward not implemented")
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) mustEmbedUnimplementedFirstRechargeRewardServiceServer() {
|
||||
}
|
||||
func (UnimplementedFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to FirstRechargeRewardServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeFirstRechargeRewardServiceServer interface {
|
||||
mustEmbedUnimplementedFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
func RegisterFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv FirstRechargeRewardServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedFirstRechargeRewardServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&FirstRechargeRewardService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetFirstRechargeRewardStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: FirstRechargeRewardService_GetFirstRechargeRewardStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(FirstRechargeRewardServiceServer).GetFirstRechargeRewardStatus(ctx, req.(*GetFirstRechargeRewardStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConsumeFirstRechargeRewardRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: FirstRechargeRewardService_ConsumeFirstRechargeReward_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(FirstRechargeRewardServiceServer).ConsumeFirstRechargeReward(ctx, req.(*ConsumeFirstRechargeRewardRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// FirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for FirstRechargeRewardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var FirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.activity.v1.FirstRechargeRewardService",
|
||||
HandlerType: (*FirstRechargeRewardServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetFirstRechargeRewardStatus",
|
||||
Handler: _FirstRechargeRewardService_GetFirstRechargeRewardStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConsumeFirstRechargeReward",
|
||||
Handler: _FirstRechargeRewardService_ConsumeFirstRechargeReward_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/GetFirstRechargeRewardConfig"
|
||||
AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/UpdateFirstRechargeRewardConfig"
|
||||
AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName = "/hyapp.activity.v1.AdminFirstRechargeRewardService/ListFirstRechargeRewardClaims"
|
||||
)
|
||||
|
||||
// AdminFirstRechargeRewardServiceClient is the client API for AdminFirstRechargeRewardService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
type AdminFirstRechargeRewardServiceClient interface {
|
||||
GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error)
|
||||
}
|
||||
|
||||
type adminFirstRechargeRewardServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAdminFirstRechargeRewardServiceClient(cc grpc.ClientConnInterface) AdminFirstRechargeRewardServiceClient {
|
||||
return &adminFirstRechargeRewardServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) GetFirstRechargeRewardConfig(ctx context.Context, in *GetFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*GetFirstRechargeRewardConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetFirstRechargeRewardConfigResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) UpdateFirstRechargeRewardConfig(ctx context.Context, in *UpdateFirstRechargeRewardConfigRequest, opts ...grpc.CallOption) (*UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UpdateFirstRechargeRewardConfigResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *adminFirstRechargeRewardServiceClient) ListFirstRechargeRewardClaims(ctx context.Context, in *ListFirstRechargeRewardClaimsRequest, opts ...grpc.CallOption) (*ListFirstRechargeRewardClaimsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListFirstRechargeRewardClaimsResponse)
|
||||
err := c.cc.Invoke(ctx, AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardServiceServer is the server API for AdminFirstRechargeRewardService service.
|
||||
// All implementations must embed UnimplementedAdminFirstRechargeRewardServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// AdminFirstRechargeRewardService 是后台活动管理访问首冲奖励配置和发放记录的唯一入口。
|
||||
type AdminFirstRechargeRewardServiceServer interface {
|
||||
GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error)
|
||||
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedAdminFirstRechargeRewardServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAdminFirstRechargeRewardServiceServer struct{}
|
||||
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) GetFirstRechargeRewardConfig(context.Context, *GetFirstRechargeRewardConfigRequest) (*GetFirstRechargeRewardConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetFirstRechargeRewardConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) UpdateFirstRechargeRewardConfig(context.Context, *UpdateFirstRechargeRewardConfigRequest) (*UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UpdateFirstRechargeRewardConfig not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) ListFirstRechargeRewardClaims(context.Context, *ListFirstRechargeRewardClaimsRequest) (*ListFirstRechargeRewardClaimsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListFirstRechargeRewardClaims not implemented")
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer() {
|
||||
}
|
||||
func (UnimplementedAdminFirstRechargeRewardServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAdminFirstRechargeRewardServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AdminFirstRechargeRewardServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAdminFirstRechargeRewardServiceServer interface {
|
||||
mustEmbedUnimplementedAdminFirstRechargeRewardServiceServer()
|
||||
}
|
||||
|
||||
func RegisterAdminFirstRechargeRewardServiceServer(s grpc.ServiceRegistrar, srv AdminFirstRechargeRewardServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedAdminFirstRechargeRewardServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AdminFirstRechargeRewardService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetFirstRechargeRewardConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).GetFirstRechargeRewardConfig(ctx, req.(*GetFirstRechargeRewardConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UpdateFirstRechargeRewardConfigRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).UpdateFirstRechargeRewardConfig(ctx, req.(*UpdateFirstRechargeRewardConfigRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListFirstRechargeRewardClaimsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(AdminFirstRechargeRewardServiceServer).ListFirstRechargeRewardClaims(ctx, req.(*ListFirstRechargeRewardClaimsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardService_ServiceDesc is the grpc.ServiceDesc for AdminFirstRechargeRewardService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AdminFirstRechargeRewardService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.activity.v1.AdminFirstRechargeRewardService",
|
||||
HandlerType: (*AdminFirstRechargeRewardServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetFirstRechargeRewardConfig",
|
||||
Handler: _AdminFirstRechargeRewardService_GetFirstRechargeRewardConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UpdateFirstRechargeRewardConfig",
|
||||
Handler: _AdminFirstRechargeRewardService_UpdateFirstRechargeRewardConfig_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListFirstRechargeRewardClaims",
|
||||
Handler: _AdminFirstRechargeRewardService_ListFirstRechargeRewardClaims_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/activity/v1/activity.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus"
|
||||
SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn"
|
||||
|
||||
@ -34,6 +34,7 @@ import (
|
||||
countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
|
||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
healthmodule "hyapp-admin-server/internal/modules/health"
|
||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -210,35 +211,36 @@ func main() {
|
||||
objectUploader = uploader
|
||||
}
|
||||
handlers := router.Handlers{
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
AdminUser: adminusermodule.New(store, cfg, auditHandler),
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store),
|
||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
|
||||
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB),
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
AdminUser: adminusermodule.New(store, cfg, auditHandler),
|
||||
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
AppConfig: appconfigmodule.New(store, auditHandler),
|
||||
AppRegistry: appregistrymodule.New(userDB),
|
||||
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
|
||||
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
|
||||
RBAC: rbacmodule.New(store, auditHandler),
|
||||
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
RegionBlock: regionblockmodule.New(userDB, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
|
||||
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB),
|
||||
}
|
||||
engine := router.New(cfg, auth, handlers)
|
||||
|
||||
|
||||
@ -18,6 +18,9 @@ type Client interface {
|
||||
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
|
||||
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
|
||||
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, error)
|
||||
GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error)
|
||||
UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error)
|
||||
ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error)
|
||||
GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
|
||||
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
|
||||
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
|
||||
@ -33,24 +36,26 @@ type Client interface {
|
||||
}
|
||||
|
||||
type GRPCClient struct {
|
||||
taskClient activityv1.AdminTaskServiceClient
|
||||
achievementClient activityv1.AdminAchievementServiceClient
|
||||
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
||||
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||
broadcastClient activityv1.BroadcastServiceClient
|
||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||
taskClient activityv1.AdminTaskServiceClient
|
||||
achievementClient activityv1.AdminAchievementServiceClient
|
||||
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
|
||||
firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
|
||||
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
|
||||
luckyGiftClient activityv1.AdminLuckyGiftServiceClient
|
||||
broadcastClient activityv1.BroadcastServiceClient
|
||||
growthClient activityv1.AdminGrowthLevelServiceClient
|
||||
}
|
||||
|
||||
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
||||
return &GRPCClient{
|
||||
taskClient: activityv1.NewAdminTaskServiceClient(conn),
|
||||
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
||||
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
||||
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||
taskClient: activityv1.NewAdminTaskServiceClient(conn),
|
||||
achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
|
||||
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
|
||||
firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
|
||||
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
|
||||
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
|
||||
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
|
||||
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,6 +91,18 @@ func (c *GRPCClient) ListRegistrationRewardClaims(ctx context.Context, req *acti
|
||||
return c.registrationRewardClient.ListRegistrationRewardClaims(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error) {
|
||||
return c.firstRechargeRewardClient.GetFirstRechargeRewardConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
return c.firstRechargeRewardClient.UpdateFirstRechargeRewardConfig(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error) {
|
||||
return c.firstRechargeRewardClient.ListFirstRechargeRewardClaims(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
|
||||
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
|
||||
}
|
||||
|
||||
348
server/admin/internal/modules/firstrechargereward/handler.go
Normal file
348
server/admin/internal/modules/firstrechargereward/handler.go
Normal file
@ -0,0 +1,348 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/activityclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
activity activityclient.Client
|
||||
userDB *sql.DB
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(activity activityclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{activity: activity, userDB: userDB, audit: audit}
|
||||
}
|
||||
|
||||
type configRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Tiers []tierDTO `json:"tiers"`
|
||||
}
|
||||
|
||||
type configDTO struct {
|
||||
AppCode string `json:"app_code"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Tiers []tierDTO `json:"tiers"`
|
||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type tierDTO struct {
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
TierName string `json:"tier_name"`
|
||||
MinCoinAmount int64 `json:"min_coin_amount"`
|
||||
MaxCoinAmount int64 `json:"max_coin_amount"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type claimDTO struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
EventID string `json:"event_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
User *userDTO `json:"user,omitempty"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
|
||||
RechargeSequence int64 `json:"recharge_sequence"`
|
||||
RechargeType string `json:"recharge_type"`
|
||||
Status string `json:"status"`
|
||||
WalletCommandID string `json:"wallet_command_id"`
|
||||
WalletGrantID string `json:"wallet_grant_id"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
RechargedAtMS int64 `json:"recharged_at_ms"`
|
||||
GrantedAtMS int64 `json:"granted_at_ms"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type userDTO struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetConfig(c *gin.Context) {
|
||||
resp, err := h.activity.GetFirstRechargeRewardConfig(c.Request.Context(), &activityv1.GetFirstRechargeRewardConfigRequest{Meta: h.meta(c)})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取首冲奖励配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, configFromProto(resp.GetConfig()))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateConfig(c *gin.Context) {
|
||||
var req configRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "首冲奖励配置参数不正确")
|
||||
return
|
||||
}
|
||||
tiers := make([]*activityv1.FirstRechargeRewardTier, 0, len(req.Tiers))
|
||||
for _, tier := range req.Tiers {
|
||||
tiers = append(tiers, &activityv1.FirstRechargeRewardTier{
|
||||
TierId: tier.TierID,
|
||||
TierCode: strings.TrimSpace(tier.TierCode),
|
||||
TierName: strings.TrimSpace(tier.TierName),
|
||||
MinCoinAmount: tier.MinCoinAmount,
|
||||
MaxCoinAmount: tier.MaxCoinAmount,
|
||||
ResourceGroupId: tier.ResourceGroupID,
|
||||
Status: strings.TrimSpace(tier.Status),
|
||||
SortOrder: tier.SortOrder,
|
||||
})
|
||||
}
|
||||
resp, err := h.activity.UpdateFirstRechargeRewardConfig(c.Request.Context(), &activityv1.UpdateFirstRechargeRewardConfigRequest{
|
||||
Meta: h.meta(c),
|
||||
Enabled: req.Enabled,
|
||||
Tiers: tiers,
|
||||
OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := configFromProto(resp.GetConfig())
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-first-recharge-reward", "first_recharge_reward_configs", item.AppCode, "success", "")
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) ListClaims(c *gin.Context) {
|
||||
options := shared.ListOptions(c)
|
||||
userID, matched, ok := h.resolveClaimUserID(c.Request.Context(), strings.TrimSpace(options.Keyword))
|
||||
if !ok {
|
||||
response.ServerError(c, "查询用户信息失败")
|
||||
return
|
||||
}
|
||||
if options.Keyword != "" && !matched {
|
||||
response.OK(c, response.Page{Items: []claimDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
|
||||
return
|
||||
}
|
||||
resp, err := h.activity.ListFirstRechargeRewardClaims(c.Request.Context(), &activityv1.ListFirstRechargeRewardClaimsRequest{
|
||||
Meta: h.meta(c),
|
||||
Status: strings.TrimSpace(options.Status),
|
||||
UserId: userID,
|
||||
Page: int32(options.Page),
|
||||
PageSize: int32(options.PageSize),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取首冲奖励发放记录失败")
|
||||
return
|
||||
}
|
||||
items := make([]claimDTO, 0, len(resp.GetClaims()))
|
||||
for _, claim := range resp.GetClaims() {
|
||||
items = append(items, claimFromProto(claim))
|
||||
}
|
||||
if err := h.fillClaimUsers(c.Request.Context(), items); err != nil {
|
||||
response.ServerError(c, "补全用户信息失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||||
}
|
||||
|
||||
func (h *Handler) meta(c *gin.Context) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
Caller: "admin-server",
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resolveClaimUserID(ctx context.Context, keyword string) (int64, bool, bool) {
|
||||
if keyword == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
if h.userDB == nil {
|
||||
return 0, false, true
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT user_id
|
||||
FROM users
|
||||
WHERE app_code = ?
|
||||
AND (
|
||||
CAST(user_id AS CHAR) = ?
|
||||
OR current_display_user_id = ?
|
||||
OR username LIKE ?
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN CAST(user_id AS CHAR) = ? THEN 0
|
||||
WHEN current_display_user_id = ? THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
user_id DESC
|
||||
LIMIT 1`,
|
||||
appCode, keyword, keyword, "%"+keyword+"%", keyword, keyword,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, false, rows.Err() == nil
|
||||
}
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
return userID, true, rows.Err() == nil
|
||||
}
|
||||
|
||||
func (h *Handler) fillClaimUsers(ctx context.Context, claims []claimDTO) error {
|
||||
if h.userDB == nil || len(claims) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := collectClaimUserIDs(claims)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
|
||||
rows, err := h.userDB.QueryContext(ctx, `
|
||||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||||
FROM users
|
||||
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)`,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make(map[int64]userDTO, len(ids))
|
||||
for rows.Next() {
|
||||
var user userDTO
|
||||
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
|
||||
return err
|
||||
}
|
||||
users[user.UserID] = user
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range claims {
|
||||
if user, ok := users[claims[index].UserID]; ok {
|
||||
claims[index].User = &user
|
||||
continue
|
||||
}
|
||||
claims[index].User = &userDTO{UserID: claims[index].UserID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configFromProto(config *activityv1.FirstRechargeRewardConfig) configDTO {
|
||||
if config == nil {
|
||||
return configDTO{Tiers: []tierDTO{}}
|
||||
}
|
||||
tiers := make([]tierDTO, 0, len(config.GetTiers()))
|
||||
for _, tier := range config.GetTiers() {
|
||||
tiers = append(tiers, tierFromProto(tier))
|
||||
}
|
||||
return configDTO{
|
||||
AppCode: config.GetAppCode(),
|
||||
Enabled: config.GetEnabled(),
|
||||
Tiers: tiers,
|
||||
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||
CreatedAtMS: config.GetCreatedAtMs(),
|
||||
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func tierFromProto(tier *activityv1.FirstRechargeRewardTier) tierDTO {
|
||||
if tier == nil {
|
||||
return tierDTO{}
|
||||
}
|
||||
return tierDTO{
|
||||
TierID: tier.GetTierId(),
|
||||
TierCode: tier.GetTierCode(),
|
||||
TierName: tier.GetTierName(),
|
||||
MinCoinAmount: tier.GetMinCoinAmount(),
|
||||
MaxCoinAmount: tier.GetMaxCoinAmount(),
|
||||
ResourceGroupID: tier.GetResourceGroupId(),
|
||||
Status: tier.GetStatus(),
|
||||
SortOrder: tier.GetSortOrder(),
|
||||
CreatedAtMS: tier.GetCreatedAtMs(),
|
||||
UpdatedAtMS: tier.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func claimFromProto(claim *activityv1.FirstRechargeRewardClaim) claimDTO {
|
||||
if claim == nil {
|
||||
return claimDTO{}
|
||||
}
|
||||
return claimDTO{
|
||||
ClaimID: claim.GetClaimId(),
|
||||
EventID: claim.GetEventId(),
|
||||
TransactionID: claim.GetTransactionId(),
|
||||
CommandID: claim.GetCommandId(),
|
||||
UserID: claim.GetUserId(),
|
||||
TierID: claim.GetTierId(),
|
||||
TierCode: claim.GetTierCode(),
|
||||
ResourceGroupID: claim.GetResourceGroupId(),
|
||||
RechargeCoinAmount: claim.GetRechargeCoinAmount(),
|
||||
RechargeSequence: claim.GetRechargeSequence(),
|
||||
RechargeType: claim.GetRechargeType(),
|
||||
Status: claim.GetStatus(),
|
||||
WalletCommandID: claim.GetWalletCommandId(),
|
||||
WalletGrantID: claim.GetWalletGrantId(),
|
||||
FailureReason: claim.GetFailureReason(),
|
||||
RechargedAtMS: claim.GetRechargedAtMs(),
|
||||
GrantedAtMS: claim.GetGrantedAtMs(),
|
||||
CreatedAtMS: claim.GetCreatedAtMs(),
|
||||
UpdatedAtMS: claim.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func collectClaimUserIDs(claims []claimDTO) []int64 {
|
||||
seen := make(map[int64]struct{}, len(claims))
|
||||
ids := make([]int64, 0, len(claims))
|
||||
for _, claim := range claims {
|
||||
if claim.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[claim.UserID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[claim.UserID] = struct{}{}
|
||||
ids = append(ids, claim.UserID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func int64Args(ids []int64) []any {
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func placeholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, count)
|
||||
for i := range parts {
|
||||
parts[i] = "?"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
16
server/admin/internal/modules/firstrechargereward/routes.go
Normal file
16
server/admin/internal/modules/firstrechargereward/routes.go
Normal file
@ -0,0 +1,16 @@
|
||||
package firstrechargereward
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:view"), h.GetConfig)
|
||||
protected.PUT("/admin/activity/first-recharge-reward/config", middleware.RequirePermission("first-recharge-reward:update"), h.UpdateConfig)
|
||||
protected.GET("/admin/activity/first-recharge-reward/claims", middleware.RequirePermission("first-recharge-reward:view"), h.ListClaims)
|
||||
}
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/countryregion"
|
||||
"hyapp-admin-server/internal/modules/dailytask"
|
||||
"hyapp-admin-server/internal/modules/dashboard"
|
||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
"hyapp-admin-server/internal/modules/health"
|
||||
"hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -39,35 +40,36 @@ import (
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
AdminUser *adminuser.Handler
|
||||
AchievementConfig *achievementconfig.Handler
|
||||
AppConfig *appconfig.Handler
|
||||
AppRegistry *appregistry.Handler
|
||||
AppUser *appuser.Handler
|
||||
Audit *audit.Handler
|
||||
Auth *authroutes.Handler
|
||||
CoinLedger *coinledger.Handler
|
||||
CountryRegion *countryregion.Handler
|
||||
DailyTask *dailytask.Handler
|
||||
Dashboard *dashboard.Handler
|
||||
Game *gamemanagement.Handler
|
||||
Health *health.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
Job *job.Handler
|
||||
LevelConfig *levelconfig.Handler
|
||||
LuckyGift *luckygift.Handler
|
||||
Menu *menu.Handler
|
||||
Payment *payment.Handler
|
||||
RBAC *rbac.Handler
|
||||
RegistrationReward *registrationreward.Handler
|
||||
RegionBlock *regionblock.Handler
|
||||
Resource *resourcemodule.Handler
|
||||
RoomAdmin *roomadmin.Handler
|
||||
RoomTreasure *roomtreasure.Handler
|
||||
Search *search.Handler
|
||||
SevenDayCheckIn *sevendaycheckin.Handler
|
||||
Upload *upload.Handler
|
||||
UserLeaderboard *userleaderboard.Handler
|
||||
AdminUser *adminuser.Handler
|
||||
AchievementConfig *achievementconfig.Handler
|
||||
AppConfig *appconfig.Handler
|
||||
AppRegistry *appregistry.Handler
|
||||
AppUser *appuser.Handler
|
||||
Audit *audit.Handler
|
||||
Auth *authroutes.Handler
|
||||
CoinLedger *coinledger.Handler
|
||||
CountryRegion *countryregion.Handler
|
||||
DailyTask *dailytask.Handler
|
||||
Dashboard *dashboard.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
Game *gamemanagement.Handler
|
||||
Health *health.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
Job *job.Handler
|
||||
LevelConfig *levelconfig.Handler
|
||||
LuckyGift *luckygift.Handler
|
||||
Menu *menu.Handler
|
||||
Payment *payment.Handler
|
||||
RBAC *rbac.Handler
|
||||
RegistrationReward *registrationreward.Handler
|
||||
RegionBlock *regionblock.Handler
|
||||
Resource *resourcemodule.Handler
|
||||
RoomAdmin *roomadmin.Handler
|
||||
RoomTreasure *roomtreasure.Handler
|
||||
Search *search.Handler
|
||||
SevenDayCheckIn *sevendaycheckin.Handler
|
||||
Upload *upload.Handler
|
||||
UserLeaderboard *userleaderboard.Handler
|
||||
}
|
||||
|
||||
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
@ -93,6 +95,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
appconfig.RegisterRoutes(protected, h.AppConfig)
|
||||
countryregion.RegisterRoutes(protected, h.CountryRegion)
|
||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
||||
regionblock.RegisterRoutes(protected, h.RegionBlock)
|
||||
gamemanagement.RegisterRoutes(protected, h.Game)
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 首冲奖励事实归 activity-service 所有,后台只维护开关、金额档位和资源组映射。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('首冲奖励查看', 'first-recharge-reward:view', 'menu', '', @now_ms, @now_ms),
|
||||
('首冲奖励更新', 'first-recharge-reward:update', 'button', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '首冲奖励', 'first-recharge-reward', '/activities/first-recharge-reward', 'workspace_premium', 'first-recharge-reward:view', 73, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'platform-admin'
|
||||
AND admin_permission.code IN ('first-recharge-reward:view', 'first-recharge-reward:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'ops-admin'
|
||||
AND admin_permission.code IN ('first-recharge-reward:view', 'first-recharge-reward:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||
AND admin_permission.code = 'first-recharge-reward:view';
|
||||
@ -13,6 +13,11 @@ mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=ut
|
||||
user_service_addr: "user-service:13005"
|
||||
wallet_service_addr: "wallet-service:13004"
|
||||
mysql_auto_migrate: false
|
||||
first_recharge_reward_worker:
|
||||
enabled: false
|
||||
wallet_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
tencent_im:
|
||||
# Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
|
||||
enabled: true
|
||||
|
||||
@ -13,6 +13,11 @@ mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=tr
|
||||
user_service_addr: "user-service.internal:13005"
|
||||
wallet_service_addr: "wallet-service.internal:13004"
|
||||
mysql_auto_migrate: false
|
||||
first_recharge_reward_worker:
|
||||
enabled: true
|
||||
wallet_mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&tls=false"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
tencent_im:
|
||||
# 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。
|
||||
enabled: true
|
||||
|
||||
@ -13,6 +13,11 @@ mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&chars
|
||||
user_service_addr: "127.0.0.1:13005"
|
||||
wallet_service_addr: "127.0.0.1:13004"
|
||||
mysql_auto_migrate: false
|
||||
first_recharge_reward_worker:
|
||||
enabled: false
|
||||
wallet_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
tencent_im:
|
||||
# activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
|
||||
enabled: true
|
||||
|
||||
@ -367,6 +367,60 @@ CREATE TABLE IF NOT EXISTS registration_reward_daily_counters (
|
||||
PRIMARY KEY (app_code, reward_day)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='注册奖励每日计数表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS first_recharge_reward_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励配置表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS first_recharge_reward_tiers (
|
||||
tier_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '首冲奖励档位 ID',
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
tier_code VARCHAR(64) NOT NULL COMMENT '档位编码',
|
||||
tier_name VARCHAR(96) NOT NULL COMMENT '档位名称',
|
||||
min_coin_amount BIGINT NOT NULL COMMENT '最小充值金币数量',
|
||||
max_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '最大充值金币数量,0 表示无上限',
|
||||
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
UNIQUE KEY uk_first_recharge_reward_tier_code (app_code, tier_code),
|
||||
KEY idx_first_recharge_reward_tier_active (app_code, status, sort_order, min_coin_amount)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励档位表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS first_recharge_reward_claims (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
claim_id VARCHAR(96) NOT NULL COMMENT '领取 ID',
|
||||
event_id VARCHAR(128) NOT NULL COMMENT 'wallet_outbox 事件 ID',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '充值交易 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '充值命令 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
tier_id BIGINT NOT NULL COMMENT '命中奖励档位 ID',
|
||||
tier_code VARCHAR(64) NOT NULL COMMENT '命中奖励档位编码',
|
||||
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
|
||||
recharge_coin_amount BIGINT NOT NULL COMMENT '充值金币数量',
|
||||
recharge_sequence BIGINT NOT NULL COMMENT '该用户充值序号',
|
||||
recharge_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '充值来源类型',
|
||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||
wallet_command_id VARCHAR(128) NOT NULL COMMENT '钱包发奖命令 ID',
|
||||
wallet_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包资源发放 ID',
|
||||
failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
|
||||
recharged_at_ms BIGINT NOT NULL COMMENT '充值发生时间,UTC epoch ms',
|
||||
granted_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, claim_id),
|
||||
UNIQUE KEY uk_first_recharge_reward_event (app_code, event_id),
|
||||
UNIQUE KEY uk_first_recharge_reward_transaction (app_code, transaction_id),
|
||||
UNIQUE KEY uk_first_recharge_reward_user_once (app_code, user_id),
|
||||
KEY idx_first_recharge_reward_list (app_code, created_at_ms, claim_id),
|
||||
KEY idx_first_recharge_reward_status (app_code, status, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励领取表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seven_day_checkin_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',
|
||||
|
||||
@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
@ -26,6 +27,7 @@ import (
|
||||
achievementservice "hyapp/services/activity-service/internal/service/achievement"
|
||||
activityservice "hyapp/services/activity-service/internal/service/activity"
|
||||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||
growthservice "hyapp/services/activity-service/internal/service/growth"
|
||||
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
|
||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||
@ -43,8 +45,12 @@ type App struct {
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
walletOutboxDB *sql.DB
|
||||
broadcast *broadcastservice.Service
|
||||
firstRechargeReward *firstrechargeservice.Service
|
||||
broadcastWorkerEnabled bool
|
||||
firstRechargeWorkerCfg config.FirstRechargeRewardWorkerConfig
|
||||
workerNodeID string
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
userConn *grpc.ClientConn
|
||||
walletConn *grpc.ClientConn
|
||||
@ -139,10 +145,37 @@ func New(cfg config.Config) (*App, error) {
|
||||
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc)
|
||||
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
|
||||
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
|
||||
var walletOutboxDB *sql.DB
|
||||
var firstRechargeReader firstrechargeservice.WalletOutboxReader
|
||||
if cfg.FirstRechargeRewardWorker.Enabled {
|
||||
walletOutboxDB, err = sql.Open("mysql", cfg.FirstRechargeRewardWorker.WalletMySQLDSN)
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
_ = repository.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := walletOutboxDB.PingContext(startupCtx); err != nil {
|
||||
_ = walletOutboxDB.Close()
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
_ = repository.Close()
|
||||
return nil, err
|
||||
}
|
||||
firstRechargeReader = mysqlstorage.NewWalletRechargeOutboxReader(walletOutboxDB)
|
||||
}
|
||||
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletv1.NewWalletServiceClient(walletConn), firstRechargeReader)
|
||||
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(firstRechargeRewardSvc))
|
||||
activityv1.RegisterAdminFirstRechargeRewardServiceServer(server, grpcserver.NewAdminFirstRechargeRewardServer(firstRechargeRewardSvc))
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
|
||||
if cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
if walletOutboxDB != nil {
|
||||
_ = walletOutboxDB.Close()
|
||||
}
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
@ -161,6 +194,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
_, err = growthSvc.HandleRoomEvent(eventCtx, envelope)
|
||||
return err
|
||||
}); err != nil {
|
||||
if walletOutboxDB != nil {
|
||||
_ = walletOutboxDB.Close()
|
||||
}
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
@ -169,13 +205,23 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
|
||||
healthDependencies := []grpchealth.Dependency{{
|
||||
Name: "mysql",
|
||||
Check: repository.Ping,
|
||||
})
|
||||
}}
|
||||
if walletOutboxDB != nil {
|
||||
healthDependencies = append(healthDependencies, grpchealth.Dependency{
|
||||
Name: "wallet_mysql",
|
||||
Check: walletOutboxDB.PingContext,
|
||||
})
|
||||
}
|
||||
health := grpchealth.NewServingChecker("activity-service", healthDependencies...)
|
||||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
|
||||
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
|
||||
if err != nil {
|
||||
if walletOutboxDB != nil {
|
||||
_ = walletOutboxDB.Close()
|
||||
}
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
@ -190,8 +236,12 @@ func New(cfg config.Config) (*App, error) {
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: repository,
|
||||
walletOutboxDB: walletOutboxDB,
|
||||
broadcast: broadcastSvc,
|
||||
firstRechargeReward: firstRechargeRewardSvc,
|
||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||
firstRechargeWorkerCfg: cfg.FirstRechargeRewardWorker,
|
||||
workerNodeID: cfg.NodeID,
|
||||
mqConsumers: mqConsumers,
|
||||
userConn: userConn,
|
||||
walletConn: walletConn,
|
||||
@ -213,6 +263,15 @@ func (a *App) Run() error {
|
||||
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
|
||||
})
|
||||
}
|
||||
if a.firstRechargeWorkerCfg.Enabled && a.firstRechargeReward != nil && a.workerCtx != nil {
|
||||
a.workerWG.Go(func() {
|
||||
a.firstRechargeReward.RunWalletRechargeWorker(a.workerCtx, firstrechargeservice.WorkerOptions{
|
||||
WorkerID: a.workerNodeID,
|
||||
PollInterval: time.Duration(a.firstRechargeWorkerCfg.PollIntervalSec) * time.Second,
|
||||
BatchSize: a.firstRechargeWorkerCfg.BatchSize,
|
||||
})
|
||||
})
|
||||
}
|
||||
err := a.server.Serve(a.listener)
|
||||
if a.workerStop != nil {
|
||||
a.workerStop()
|
||||
@ -244,6 +303,9 @@ func (a *App) Close() {
|
||||
if a.walletConn != nil {
|
||||
_ = a.walletConn.Close()
|
||||
}
|
||||
if a.walletOutboxDB != nil {
|
||||
_ = a.walletOutboxDB.Close()
|
||||
}
|
||||
if a.mysqlRepo != nil {
|
||||
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
|
||||
_ = a.mysqlRepo.Close()
|
||||
|
||||
@ -25,6 +25,8 @@ type Config struct {
|
||||
UserServiceAddr string `yaml:"user_service_addr"`
|
||||
// WalletServiceAddr 是任务奖励入账依赖;activity 只发命令,不直接写余额。
|
||||
WalletServiceAddr string `yaml:"wallet_service_addr"`
|
||||
// FirstRechargeRewardWorker 控制首冲奖励对 wallet_outbox 充值事实的本地消费。
|
||||
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
|
||||
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
|
||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
|
||||
@ -49,6 +51,18 @@ type MessageInboxConfig struct {
|
||||
UnreadCacheTTL string `yaml:"unread_cache_ttl"`
|
||||
}
|
||||
|
||||
// FirstRechargeRewardWorkerConfig 保存钱包充值事实消费策略。
|
||||
type FirstRechargeRewardWorkerConfig struct {
|
||||
// Enabled 控制是否启动 wallet_outbox 轮询;关闭后仍可通过 gRPC 消费入口补偿。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// WalletMySQLDSN 是 wallet-service outbox 只读连接串。
|
||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||
// PollIntervalSec 是没有积压时两轮扫描间隔秒数。
|
||||
PollIntervalSec int64 `yaml:"poll_interval_sec"`
|
||||
// BatchSize 是单轮最多消费的 WalletRechargeRecorded 事件数。
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
}
|
||||
|
||||
// TencentIMConfig 描述 activity-service 调用腾讯云 IM REST API 的配置。
|
||||
type TencentIMConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
@ -121,6 +135,12 @@ func Default() Config {
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
UserServiceAddr: "127.0.0.1:13005",
|
||||
WalletServiceAddr: "127.0.0.1:13004",
|
||||
FirstRechargeRewardWorker: FirstRechargeRewardWorkerConfig{
|
||||
Enabled: false,
|
||||
WalletMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
PollIntervalSec: 5,
|
||||
BatchSize: 100,
|
||||
},
|
||||
TencentIM: TencentIMConfig{
|
||||
Enabled: false,
|
||||
AdminIdentifier: "administrator",
|
||||
@ -183,6 +203,16 @@ func Load(path string) (Config, error) {
|
||||
if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
|
||||
cfg.WalletServiceAddr = Default().WalletServiceAddr
|
||||
}
|
||||
cfg.FirstRechargeRewardWorker.WalletMySQLDSN = strings.TrimSpace(cfg.FirstRechargeRewardWorker.WalletMySQLDSN)
|
||||
if cfg.FirstRechargeRewardWorker.WalletMySQLDSN == "" {
|
||||
cfg.FirstRechargeRewardWorker.WalletMySQLDSN = Default().FirstRechargeRewardWorker.WalletMySQLDSN
|
||||
}
|
||||
if cfg.FirstRechargeRewardWorker.PollIntervalSec <= 0 {
|
||||
cfg.FirstRechargeRewardWorker.PollIntervalSec = Default().FirstRechargeRewardWorker.PollIntervalSec
|
||||
}
|
||||
if cfg.FirstRechargeRewardWorker.BatchSize <= 0 {
|
||||
cfg.FirstRechargeRewardWorker.BatchSize = Default().FirstRechargeRewardWorker.BatchSize
|
||||
}
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "activity-service"
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
package firstrecharge
|
||||
|
||||
const (
|
||||
TierStatusActive = "active"
|
||||
TierStatusInactive = "inactive"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusGranted = "granted"
|
||||
StatusFailed = "failed"
|
||||
|
||||
ReasonEligible = "eligible"
|
||||
ReasonNotConfigured = "not_configured"
|
||||
ReasonDisabled = "disabled"
|
||||
ReasonInvalidConfig = "invalid_config"
|
||||
ReasonNotFirst = "not_first_recharge"
|
||||
ReasonTierNotMatched = "tier_not_matched"
|
||||
ReasonAlreadyGranted = "already_granted"
|
||||
ReasonPendingReward = "pending_reward"
|
||||
|
||||
RechargeEventWalletRecorded = "WalletRechargeRecorded"
|
||||
RechargeTypeCoinSeller = "coin_seller_transfer"
|
||||
)
|
||||
|
||||
// Tier 是首冲奖励的金额档位,max_coin_amount=0 表达无上限。
|
||||
type Tier struct {
|
||||
TierID int64
|
||||
TierCode string
|
||||
TierName string
|
||||
MinCoinAmount int64
|
||||
MaxCoinAmount int64
|
||||
ResourceGroupID int64
|
||||
Status string
|
||||
SortOrder int32
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// Config 是首冲奖励当前配置;档位快照由 activity-service 持有。
|
||||
type Config struct {
|
||||
AppCode string
|
||||
Enabled bool
|
||||
Tiers []Tier
|
||||
UpdatedByAdminID int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// Claim 是首笔充值触发后的发奖事实;app_code + user_id 唯一。
|
||||
type Claim struct {
|
||||
ClaimID string
|
||||
EventID string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
UserID int64
|
||||
TierID int64
|
||||
TierCode string
|
||||
ResourceGroupID int64
|
||||
RechargeCoinAmount int64
|
||||
RechargeSequence int64
|
||||
RechargeType string
|
||||
Status string
|
||||
WalletCommandID string
|
||||
WalletGrantID string
|
||||
FailureReason string
|
||||
RechargedAtMS int64
|
||||
GrantedAtMS int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// StatusResult 是 App 首冲奖励面板读取的稳定投影。
|
||||
type StatusResult struct {
|
||||
Enabled bool
|
||||
Tiers []Tier
|
||||
Rewarded bool
|
||||
Claim Claim
|
||||
ServerTimeMS int64
|
||||
}
|
||||
|
||||
// RechargeEvent 是 wallet_outbox 中的成功充值事实。
|
||||
type RechargeEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
UserID int64
|
||||
RechargeCoinAmount int64
|
||||
RechargeSequence int64
|
||||
RechargeType string
|
||||
PayloadJSON string
|
||||
OccurredAtMS int64
|
||||
}
|
||||
|
||||
// PrepareResult 表达 activity 事务是否已经创建或复用 pending claim。
|
||||
type PrepareResult struct {
|
||||
Claim Claim
|
||||
Prepared bool
|
||||
AlreadyGranted bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ClaimQuery 是后台发放记录分页查询条件。
|
||||
type ClaimQuery struct {
|
||||
Status string
|
||||
UserID int64
|
||||
Page int32
|
||||
PageSize int32
|
||||
}
|
||||
|
||||
// WalletRechargeCursor 让 worker 按 wallet_outbox 创建顺序稳定扫描。
|
||||
type WalletRechargeCursor struct {
|
||||
CreatedAtMS int64
|
||||
EventID string
|
||||
}
|
||||
@ -0,0 +1,366 @@
|
||||
package firstrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||
)
|
||||
|
||||
const (
|
||||
grantSource = "first_recharge_reward"
|
||||
grantReason = "first_recharge_reward"
|
||||
)
|
||||
|
||||
// Repository 是首冲奖励配置、claim 幂等和发放事实的唯一持久化边界。
|
||||
type Repository interface {
|
||||
GetFirstRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error)
|
||||
UpdateFirstRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||||
GetFirstRechargeRewardClaimByUser(ctx context.Context, userID int64) (domain.Claim, bool, error)
|
||||
PrepareFirstRechargeRewardClaim(ctx context.Context, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error)
|
||||
MarkFirstRechargeRewardClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error)
|
||||
MarkFirstRechargeRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
||||
ListFirstRechargeRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
|
||||
}
|
||||
|
||||
// WalletClient 是首冲奖励唯一外部副作用;资源组展开由 wallet-service 原子完成。
|
||||
type WalletClient interface {
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||||
}
|
||||
|
||||
// WalletOutboxReader 扫描 wallet-service 的充值事实;activity 不修改 wallet_outbox 状态。
|
||||
type WalletOutboxReader interface {
|
||||
ListRechargeEventsAfter(ctx context.Context, cursor domain.WalletRechargeCursor, batchSize int) ([]domain.RechargeEvent, error)
|
||||
}
|
||||
|
||||
// WorkerOptions 控制本地 wallet_outbox 轮询。
|
||||
type WorkerOptions struct {
|
||||
WorkerID string
|
||||
PollInterval time.Duration
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
// Service 承载首冲奖励 App 查询、后台配置和充值事实消费用例。
|
||||
type Service struct {
|
||||
repository Repository
|
||||
wallet WalletClient
|
||||
reader WalletOutboxReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, wallet WalletClient, reader WalletOutboxReader) *Service {
|
||||
return &Service{repository: repository, wallet: wallet, reader: reader, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) SetClock(now func() time.Time) {
|
||||
if now != nil {
|
||||
s.now = now
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus 返回 App 可展示的当前配置和用户发放状态;未配置时不是错误。
|
||||
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusResult, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.StatusResult{}, err
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
result := domain.StatusResult{ServerTimeMS: nowMS}
|
||||
config, exists, err := s.repository.GetFirstRechargeRewardConfig(ctx)
|
||||
if err != nil {
|
||||
return domain.StatusResult{}, err
|
||||
}
|
||||
if exists {
|
||||
result.Enabled = config.Enabled
|
||||
result.Tiers = activeTiers(config.Tiers)
|
||||
}
|
||||
claim, claimed, err := s.repository.GetFirstRechargeRewardClaimByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.StatusResult{}, err
|
||||
}
|
||||
if claimed {
|
||||
result.Claim = claim
|
||||
result.Rewarded = claim.Status == domain.StatusGranted
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
config, exists, err := s.repository.GetFirstRechargeRewardConfig(ctx)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Config{AppCode: appcode.FromContext(ctx)}, nil
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
config.AppCode = appcode.FromContext(ctx)
|
||||
config.Tiers = normalizeTiers(config.Tiers)
|
||||
if err := validateConfig(config); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
return s.repository.UpdateFirstRechargeRewardConfig(ctx, config, s.now().UnixMilli())
|
||||
}
|
||||
|
||||
// Consume 处理 wallet 成功充值事实;只有 recharge_sequence=1 且命中启用档位才发奖。
|
||||
func (s *Service) Consume(ctx context.Context, event domain.RechargeEvent) (domain.Claim, bool, string, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return domain.Claim{}, false, "", err
|
||||
}
|
||||
event = normalizeRechargeEvent(event)
|
||||
if err := validateRechargeEvent(event); err != nil {
|
||||
return domain.Claim{}, false, "", err
|
||||
}
|
||||
if event.RechargeSequence != 1 {
|
||||
return domain.Claim{}, false, domain.ReasonNotFirst, nil
|
||||
}
|
||||
prepared, err := s.repository.PrepareFirstRechargeRewardClaim(ctx, event, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
return domain.Claim{}, false, "", err
|
||||
}
|
||||
if prepared.AlreadyGranted {
|
||||
return prepared.Claim, true, prepared.Reason, nil
|
||||
}
|
||||
if !prepared.Prepared {
|
||||
return prepared.Claim, false, prepared.Reason, nil
|
||||
}
|
||||
if s.wallet == nil {
|
||||
return domain.Claim{}, false, "", xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||
}
|
||||
|
||||
claim := prepared.Claim
|
||||
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||
CommandId: claim.WalletCommandID,
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TargetUserId: claim.UserID,
|
||||
GroupId: claim.ResourceGroupID,
|
||||
Reason: grantReason,
|
||||
OperatorUserId: claim.UserID,
|
||||
GrantSource: grantSource,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.repository.MarkFirstRechargeRewardClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
|
||||
return domain.Claim{}, false, "", err
|
||||
}
|
||||
walletGrantID := ""
|
||||
if resp.GetGrant() != nil {
|
||||
walletGrantID = resp.GetGrant().GetGrantId()
|
||||
}
|
||||
claim, err = s.repository.MarkFirstRechargeRewardClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UnixMilli())
|
||||
if err != nil {
|
||||
return domain.Claim{}, false, "", err
|
||||
}
|
||||
return claim, true, domain.ReasonEligible, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
|
||||
if err := s.requireRepository(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query.Status = strings.TrimSpace(query.Status)
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize <= 0 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
if query.PageSize > 100 {
|
||||
query.PageSize = 100
|
||||
}
|
||||
return s.repository.ListFirstRechargeRewardClaims(ctx, query)
|
||||
}
|
||||
|
||||
// RunWalletRechargeWorker 轮询 wallet_outbox 的充值事实;游标只在内存保存,claim 表唯一键保证重复扫描安全。
|
||||
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
|
||||
if s == nil || s.repository == nil || s.reader == nil {
|
||||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "first_recharge_reward"), slog.String("reason", "missing_dependency"))
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = 5 * time.Second
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
var cursor domain.WalletRechargeCursor
|
||||
for {
|
||||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||||
if err != nil {
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "first_recharge_reward"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if processed >= options.BatchSize {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(options.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) processBatch(ctx context.Context, cursor *domain.WalletRechargeCursor, batchSize int) (int, error) {
|
||||
events, err := s.reader.ListRechargeEventsAfter(ctx, *cursor, batchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, event := range events {
|
||||
eventCtx := appcode.WithContext(ctx, event.AppCode)
|
||||
if _, _, _, err := s.Consume(eventCtx, event); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cursor.CreatedAtMS = event.OccurredAtMS
|
||||
cursor.EventID = event.EventID
|
||||
}
|
||||
return len(events), nil
|
||||
}
|
||||
|
||||
func (s *Service) requireRepository() error {
|
||||
if s == nil || s.repository == nil {
|
||||
return xerr.New(xerr.Unavailable, "first recharge reward repository is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTiers(tiers []domain.Tier) []domain.Tier {
|
||||
items := make([]domain.Tier, 0, len(tiers))
|
||||
for _, tier := range tiers {
|
||||
status := strings.ToLower(strings.TrimSpace(tier.Status))
|
||||
if status == "" {
|
||||
status = domain.TierStatusActive
|
||||
}
|
||||
items = append(items, domain.Tier{
|
||||
TierID: tier.TierID,
|
||||
TierCode: strings.TrimSpace(tier.TierCode),
|
||||
TierName: strings.TrimSpace(tier.TierName),
|
||||
MinCoinAmount: tier.MinCoinAmount,
|
||||
MaxCoinAmount: tier.MaxCoinAmount,
|
||||
ResourceGroupID: tier.ResourceGroupID,
|
||||
Status: status,
|
||||
SortOrder: tier.SortOrder,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].SortOrder == items[j].SortOrder {
|
||||
return items[i].MinCoinAmount < items[j].MinCoinAmount
|
||||
}
|
||||
return items[i].SortOrder < items[j].SortOrder
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func validateConfig(config domain.Config) error {
|
||||
if config.UpdatedByAdminID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(config.Tiers))
|
||||
active := make([]domain.Tier, 0, len(config.Tiers))
|
||||
for _, tier := range config.Tiers {
|
||||
if tier.TierCode == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "tier_code is required")
|
||||
}
|
||||
if _, exists := seen[tier.TierCode]; exists {
|
||||
return xerr.New(xerr.InvalidArgument, "tier_code is duplicated")
|
||||
}
|
||||
seen[tier.TierCode] = struct{}{}
|
||||
if tier.TierName == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "tier_name is required")
|
||||
}
|
||||
if tier.MinCoinAmount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "min_coin_amount must be greater than zero")
|
||||
}
|
||||
if tier.MaxCoinAmount > 0 && tier.MaxCoinAmount < tier.MinCoinAmount {
|
||||
return xerr.New(xerr.InvalidArgument, "max_coin_amount must be greater than min_coin_amount")
|
||||
}
|
||||
if tier.ResourceGroupID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource_group_id is required")
|
||||
}
|
||||
switch tier.Status {
|
||||
case domain.TierStatusActive:
|
||||
active = append(active, tier)
|
||||
case domain.TierStatusInactive:
|
||||
default:
|
||||
return xerr.New(xerr.InvalidArgument, "tier status is invalid")
|
||||
}
|
||||
}
|
||||
if config.Enabled && len(active) == 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "enabled config requires at least one active tier")
|
||||
}
|
||||
if hasOverlappedTiers(active) {
|
||||
return xerr.New(xerr.InvalidArgument, "active tiers overlap")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasOverlappedTiers(tiers []domain.Tier) bool {
|
||||
sort.SliceStable(tiers, func(i, j int) bool { return tiers[i].MinCoinAmount < tiers[j].MinCoinAmount })
|
||||
var previousMax int64
|
||||
for i, tier := range tiers {
|
||||
if i > 0 && (previousMax == 0 || tier.MinCoinAmount <= previousMax) {
|
||||
return true
|
||||
}
|
||||
previousMax = tier.MaxCoinAmount
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func activeTiers(tiers []domain.Tier) []domain.Tier {
|
||||
items := make([]domain.Tier, 0, len(tiers))
|
||||
for _, tier := range tiers {
|
||||
if tier.Status == domain.TierStatusActive {
|
||||
items = append(items, tier)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeRechargeEvent(event domain.RechargeEvent) domain.RechargeEvent {
|
||||
event.AppCode = appcode.Normalize(event.AppCode)
|
||||
event.EventID = strings.TrimSpace(event.EventID)
|
||||
event.EventType = strings.TrimSpace(event.EventType)
|
||||
event.TransactionID = strings.TrimSpace(event.TransactionID)
|
||||
event.CommandID = strings.TrimSpace(event.CommandID)
|
||||
event.RechargeType = strings.TrimSpace(event.RechargeType)
|
||||
if event.RechargeType == "" {
|
||||
event.RechargeType = domain.RechargeTypeCoinSeller
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func validateRechargeEvent(event domain.RechargeEvent) error {
|
||||
if event.EventID == "" || event.TransactionID == "" || event.CommandID == "" || event.UserID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "first recharge event is incomplete")
|
||||
}
|
||||
if event.EventType != "" && event.EventType != domain.RechargeEventWalletRecorded {
|
||||
return xerr.New(xerr.InvalidArgument, "first recharge event type is invalid")
|
||||
}
|
||||
if event.RechargeCoinAmount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "recharge_coin_amount must be greater than zero")
|
||||
}
|
||||
if event.OccurredAtMS <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "occurred_at_ms is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package firstrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||
"hyapp/services/activity-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestFirstRechargeRewardConsumesRechargeFactWithMySQL(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &fakeWallet{}
|
||||
svc := New(repository, wallet, nil)
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
|
||||
config, err := svc.UpdateConfig(ctx, domain.Config{
|
||||
Enabled: true,
|
||||
UpdatedByAdminID: 90001,
|
||||
Tiers: []domain.Tier{{
|
||||
TierCode: "first_100",
|
||||
TierName: "首冲 100+",
|
||||
MinCoinAmount: 100,
|
||||
MaxCoinAmount: 0,
|
||||
ResourceGroupID: 88001,
|
||||
Status: domain.TierStatusActive,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConfig failed: %v", err)
|
||||
}
|
||||
if len(config.Tiers) != 1 || config.Tiers[0].TierID <= 0 {
|
||||
t.Fatalf("tier id was not generated: %+v", config.Tiers)
|
||||
}
|
||||
|
||||
event := domain.RechargeEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "evt-recharge-1",
|
||||
EventType: domain.RechargeEventWalletRecorded,
|
||||
TransactionID: "wtx-recharge-1",
|
||||
CommandID: "seller-transfer-1",
|
||||
UserID: 42001,
|
||||
RechargeCoinAmount: 500,
|
||||
RechargeSequence: 1,
|
||||
RechargeType: domain.RechargeTypeCoinSeller,
|
||||
OccurredAtMS: 1710000000000,
|
||||
}
|
||||
claim, consumed, reason, err := svc.Consume(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("Consume failed: %v", err)
|
||||
}
|
||||
if !consumed || reason != domain.ReasonEligible || claim.Status != domain.StatusGranted || claim.ResourceGroupID != 88001 {
|
||||
t.Fatalf("consume result mismatch: claim=%+v consumed=%v reason=%s", claim, consumed, reason)
|
||||
}
|
||||
if len(wallet.grants) != 1 || wallet.grants[0].GroupId != 88001 || wallet.grants[0].TargetUserId != 42001 {
|
||||
t.Fatalf("wallet grant mismatch: %+v", wallet.grants)
|
||||
}
|
||||
|
||||
status, err := svc.GetStatus(ctx, 42001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStatus failed: %v", err)
|
||||
}
|
||||
if !status.Rewarded || status.Claim.ClaimID != claim.ClaimID {
|
||||
t.Fatalf("status should include granted claim: %+v", status)
|
||||
}
|
||||
|
||||
_, consumed, reason, err = svc.Consume(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("retry Consume failed: %v", err)
|
||||
}
|
||||
if !consumed || reason != domain.ReasonAlreadyGranted || len(wallet.grants) != 1 {
|
||||
t.Fatalf("retry should be idempotent: consumed=%v reason=%s grants=%d", consumed, reason, len(wallet.grants))
|
||||
}
|
||||
|
||||
event.EventID = "evt-recharge-2"
|
||||
event.TransactionID = "wtx-recharge-2"
|
||||
event.CommandID = "seller-transfer-2"
|
||||
event.RechargeSequence = 2
|
||||
_, consumed, reason, err = svc.Consume(ctx, event)
|
||||
if err != nil {
|
||||
t.Fatalf("second recharge Consume failed: %v", err)
|
||||
}
|
||||
if consumed || reason != domain.ReasonNotFirst || len(wallet.grants) != 1 {
|
||||
t.Fatalf("second recharge should be ignored: consumed=%v reason=%s grants=%d", consumed, reason, len(wallet.grants))
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWallet struct {
|
||||
grants []*walletv1.GrantResourceGroupRequest
|
||||
}
|
||||
|
||||
func (f *fakeWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
|
||||
f.grants = append(f.grants, req)
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
|
||||
AppCode: req.GetAppCode(),
|
||||
GrantId: "grant-first-recharge",
|
||||
CommandId: req.GetCommandId(),
|
||||
TargetUserId: req.GetTargetUserId(),
|
||||
GrantSource: req.GetGrantSource(),
|
||||
Status: "granted",
|
||||
}}, nil
|
||||
}
|
||||
@ -0,0 +1,557 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||
)
|
||||
|
||||
// GetFirstRechargeRewardConfig 读取首冲奖励当前配置;未配置是后台首屏正常状态。
|
||||
func (r *Repository) GetFirstRechargeRewardConfig(ctx context.Context) (domain.Config, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, firstRechargeRewardConfigSelectSQL()+`
|
||||
WHERE app_code = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
config, err := scanFirstRechargeRewardConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Config{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Config{}, false, err
|
||||
}
|
||||
tiers, err := r.listFirstRechargeRewardTiers(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Config{}, false, err
|
||||
}
|
||||
config.Tiers = tiers
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
// UpdateFirstRechargeRewardConfig 原子替换配置档位;claim 保留命中档位快照,不受后续配置变更影响。
|
||||
func (r *Repository) UpdateFirstRechargeRewardConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO first_recharge_reward_configs (
|
||||
app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appcode.FromContext(ctx), config.Enabled, config.UpdatedByAdminID, nowMS, nowMS,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM first_recharge_reward_tiers
|
||||
WHERE app_code = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
for i := range config.Tiers {
|
||||
tier := config.Tiers[i]
|
||||
tier.CreatedAtMS = nowMS
|
||||
tier.UpdatedAtMS = nowMS
|
||||
if tier.TierID > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO first_recharge_reward_tiers (
|
||||
tier_id, app_code, tier_code, tier_name, min_coin_amount, max_coin_amount,
|
||||
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tier.TierID, appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.MinCoinAmount,
|
||||
tier.MaxCoinAmount, tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO first_recharge_reward_tiers (
|
||||
app_code, tier_code, tier_name, min_coin_amount, max_coin_amount,
|
||||
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
appcode.FromContext(ctx), tier.TierCode, tier.TierName, tier.MinCoinAmount, tier.MaxCoinAmount,
|
||||
tier.ResourceGroupID, tier.Status, tier.SortOrder, tier.CreatedAtMS, tier.UpdatedAtMS,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
tierID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
config.Tiers[i].TierID = tierID
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
updated, exists, err := r.GetFirstRechargeRewardConfig(ctx)
|
||||
if err != nil {
|
||||
return domain.Config{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Config{}, xerr.New(xerr.NotFound, "first recharge reward config not found")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetFirstRechargeRewardClaimByUser 返回 App 查询需要的用户首冲奖励事实。
|
||||
func (r *Repository) GetFirstRechargeRewardClaimByUser(ctx context.Context, userID int64) (domain.Claim, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Claim{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
WHERE app_code = ? AND user_id = ?`,
|
||||
appcode.FromContext(ctx), userID,
|
||||
)
|
||||
claim, err := scanFirstRechargeRewardClaim(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Claim{}, false, nil
|
||||
}
|
||||
return claim, err == nil, err
|
||||
}
|
||||
|
||||
// PrepareFirstRechargeRewardClaim 在 activity 事务内完成配置匹配、幂等判断和 pending claim 写入。
|
||||
func (r *Repository) PrepareFirstRechargeRewardClaim(ctx context.Context, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.PrepareResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if event.RechargeSequence != 1 {
|
||||
return domain.PrepareResult{Reason: domain.ReasonNotFirst}, tx.Commit()
|
||||
}
|
||||
config, exists, err := r.getFirstRechargeRewardConfigForUpdate(ctx, tx)
|
||||
if err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.PrepareResult{Reason: domain.ReasonNotConfigured}, tx.Commit()
|
||||
}
|
||||
if !config.Enabled {
|
||||
return domain.PrepareResult{Reason: domain.ReasonDisabled}, tx.Commit()
|
||||
}
|
||||
tiers, err := r.listFirstRechargeRewardTiersForUpdate(ctx, tx)
|
||||
if err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
tier, matched := matchFirstRechargeRewardTier(tiers, event.RechargeCoinAmount)
|
||||
if !matched {
|
||||
return domain.PrepareResult{Reason: domain.ReasonTierNotMatched}, tx.Commit()
|
||||
}
|
||||
|
||||
existing, exists, err := r.getFirstRechargeRewardClaimByEventForUpdate(ctx, tx, event.EventID)
|
||||
if err != nil || exists {
|
||||
return r.prepareExistingFirstRechargeRewardClaim(ctx, tx, existing, exists, err, event, nowMS)
|
||||
}
|
||||
existing, exists, err = r.getFirstRechargeRewardClaimByTransactionForUpdate(ctx, tx, event.TransactionID)
|
||||
if err != nil || exists {
|
||||
return r.prepareExistingFirstRechargeRewardClaim(ctx, tx, existing, exists, err, event, nowMS)
|
||||
}
|
||||
existing, exists, err = r.getFirstRechargeRewardClaimByUserForUpdate(ctx, tx, event.UserID)
|
||||
if err != nil || exists {
|
||||
return r.prepareExistingFirstRechargeRewardClaim(ctx, tx, existing, exists, err, event, nowMS)
|
||||
}
|
||||
|
||||
claim := domain.Claim{
|
||||
ClaimID: idgen.New("frclaim"),
|
||||
EventID: event.EventID,
|
||||
TransactionID: event.TransactionID,
|
||||
CommandID: event.CommandID,
|
||||
UserID: event.UserID,
|
||||
TierID: tier.TierID,
|
||||
TierCode: tier.TierCode,
|
||||
ResourceGroupID: tier.ResourceGroupID,
|
||||
RechargeCoinAmount: event.RechargeCoinAmount,
|
||||
RechargeSequence: event.RechargeSequence,
|
||||
RechargeType: event.RechargeType,
|
||||
Status: domain.StatusPending,
|
||||
RechargedAtMS: event.OccurredAtMS,
|
||||
CreatedAtMS: nowMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
}
|
||||
claim.WalletCommandID = walletFirstRechargeRewardCommandID(claim.ClaimID)
|
||||
if err := r.insertFirstRechargeRewardClaim(ctx, tx, claim); err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
return domain.PrepareResult{Claim: claim, Prepared: true, Reason: domain.ReasonEligible}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) prepareExistingFirstRechargeRewardClaim(ctx context.Context, tx *sql.Tx, claim domain.Claim, exists bool, sourceErr error, event domain.RechargeEvent, nowMS int64) (domain.PrepareResult, error) {
|
||||
if sourceErr != nil {
|
||||
return domain.PrepareResult{}, sourceErr
|
||||
}
|
||||
if !exists {
|
||||
return domain.PrepareResult{}, nil
|
||||
}
|
||||
if claim.UserID != event.UserID {
|
||||
return domain.PrepareResult{}, xerr.New(xerr.RequestConflict, "first recharge reward event payload conflicts")
|
||||
}
|
||||
if claim.Status == domain.StatusGranted {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
return domain.PrepareResult{Claim: claim, AlreadyGranted: true, Reason: domain.ReasonAlreadyGranted}, nil
|
||||
}
|
||||
if claim.Status == domain.StatusFailed {
|
||||
if err := r.updateFirstRechargeRewardClaimPending(ctx, tx, claim.ClaimID, nowMS); err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
claim.Status = domain.StatusPending
|
||||
claim.FailureReason = ""
|
||||
claim.UpdatedAtMS = nowMS
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.PrepareResult{}, err
|
||||
}
|
||||
return domain.PrepareResult{Claim: claim, Prepared: true, Reason: domain.ReasonPendingReward}, nil
|
||||
}
|
||||
|
||||
// MarkFirstRechargeRewardClaimGranted 写回 wallet-service 资源组发放回执。
|
||||
func (r *Repository) MarkFirstRechargeRewardClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE first_recharge_reward_claims
|
||||
SET status = 'granted',
|
||||
wallet_grant_id = ?,
|
||||
failure_reason = '',
|
||||
granted_at_ms = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE app_code = ? AND claim_id = ? AND status <> 'granted'`,
|
||||
walletGrantID, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), claimID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Claim{}, err
|
||||
}
|
||||
claim, exists, err := r.getFirstRechargeRewardClaimByID(ctx, claimID)
|
||||
if err != nil {
|
||||
return domain.Claim{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.Claim{}, xerr.New(xerr.NotFound, "first recharge reward claim not found")
|
||||
}
|
||||
return claim, nil
|
||||
}
|
||||
|
||||
// MarkFirstRechargeRewardClaimFailed 保留 claim,后续同一 wallet 事实重试会复用原钱包命令。
|
||||
func (r *Repository) MarkFirstRechargeRewardClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE first_recharge_reward_claims
|
||||
SET status = 'failed', failure_reason = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND claim_id = ? AND status <> 'granted'`,
|
||||
truncateTaskFailure(failureReason), nowMS, appcode.FromContext(ctx), claimID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListFirstRechargeRewardClaims 返回后台发放记录,按创建时间倒序。
|
||||
func (r *Repository) ListFirstRechargeRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
where, args := firstRechargeRewardClaimWhere(ctx, query)
|
||||
var total int64
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM first_recharge_reward_claims `+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pageSize := normalizePageSize(query.PageSize)
|
||||
page := query.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
rows, err := r.db.QueryContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
`+where+`
|
||||
ORDER BY created_at_ms DESC, claim_id DESC
|
||||
LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirstRechargeRewardClaims(rows, total)
|
||||
}
|
||||
|
||||
func (r *Repository) getFirstRechargeRewardConfigForUpdate(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, firstRechargeRewardConfigSelectSQL()+`
|
||||
WHERE app_code = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
config, err := scanFirstRechargeRewardConfig(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Config{}, false, nil
|
||||
}
|
||||
return config, err == nil, err
|
||||
}
|
||||
|
||||
func (r *Repository) listFirstRechargeRewardTiers(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if tx != nil {
|
||||
rows, err = tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
|
||||
WHERE app_code = ?
|
||||
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
} else {
|
||||
rows, err = r.db.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
|
||||
WHERE app_code = ?
|
||||
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirstRechargeRewardTiers(rows)
|
||||
}
|
||||
|
||||
func (r *Repository) listFirstRechargeRewardTiersForUpdate(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
|
||||
rows, err := tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
|
||||
WHERE app_code = ?
|
||||
ORDER BY sort_order ASC, min_coin_amount ASC, tier_id ASC
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFirstRechargeRewardTiers(rows)
|
||||
}
|
||||
|
||||
func (r *Repository) getFirstRechargeRewardClaimByEventForUpdate(ctx context.Context, tx *sql.Tx, eventID string) (domain.Claim, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), eventID,
|
||||
)
|
||||
claim, err := scanFirstRechargeRewardClaim(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Claim{}, false, nil
|
||||
}
|
||||
return claim, err == nil, err
|
||||
}
|
||||
|
||||
func (r *Repository) getFirstRechargeRewardClaimByTransactionForUpdate(ctx context.Context, tx *sql.Tx, transactionID string) (domain.Claim, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
WHERE app_code = ? AND transaction_id = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), transactionID,
|
||||
)
|
||||
claim, err := scanFirstRechargeRewardClaim(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Claim{}, false, nil
|
||||
}
|
||||
return claim, err == nil, err
|
||||
}
|
||||
|
||||
func (r *Repository) getFirstRechargeRewardClaimByUserForUpdate(ctx context.Context, tx *sql.Tx, userID int64) (domain.Claim, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), userID,
|
||||
)
|
||||
claim, err := scanFirstRechargeRewardClaim(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Claim{}, false, nil
|
||||
}
|
||||
return claim, err == nil, err
|
||||
}
|
||||
|
||||
func (r *Repository) getFirstRechargeRewardClaimByID(ctx context.Context, claimID string) (domain.Claim, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx, firstRechargeRewardClaimSelectSQL()+`
|
||||
WHERE app_code = ? AND claim_id = ?`,
|
||||
appcode.FromContext(ctx), claimID,
|
||||
)
|
||||
claim, err := scanFirstRechargeRewardClaim(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Claim{}, false, nil
|
||||
}
|
||||
return claim, err == nil, err
|
||||
}
|
||||
|
||||
func (r *Repository) insertFirstRechargeRewardClaim(ctx context.Context, tx *sql.Tx, claim domain.Claim) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO first_recharge_reward_claims (
|
||||
app_code, claim_id, event_id, transaction_id, command_id, user_id,
|
||||
tier_id, tier_code, resource_group_id, recharge_coin_amount, recharge_sequence,
|
||||
recharge_type, status, wallet_command_id, wallet_grant_id, failure_reason,
|
||||
recharged_at_ms, granted_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', ?, 0, ?, ?)`,
|
||||
appcode.FromContext(ctx), claim.ClaimID, claim.EventID, claim.TransactionID, claim.CommandID, claim.UserID,
|
||||
claim.TierID, claim.TierCode, claim.ResourceGroupID, claim.RechargeCoinAmount, claim.RechargeSequence,
|
||||
claim.RechargeType, claim.Status, claim.WalletCommandID, claim.RechargedAtMS, claim.CreatedAtMS, claim.UpdatedAtMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) updateFirstRechargeRewardClaimPending(ctx context.Context, tx *sql.Tx, claimID string, nowMS int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE first_recharge_reward_claims
|
||||
SET status = 'pending',
|
||||
wallet_grant_id = '',
|
||||
failure_reason = '',
|
||||
granted_at_ms = 0,
|
||||
updated_at_ms = ?
|
||||
WHERE app_code = ? AND claim_id = ? AND status = 'failed'`,
|
||||
nowMS, appcode.FromContext(ctx), claimID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanFirstRechargeRewardConfig(row rowScanner) (domain.Config, error) {
|
||||
var config domain.Config
|
||||
err := row.Scan(&config.AppCode, &config.Enabled, &config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS)
|
||||
return config, err
|
||||
}
|
||||
|
||||
func scanFirstRechargeRewardTiers(rows *sql.Rows) ([]domain.Tier, error) {
|
||||
items := make([]domain.Tier, 0)
|
||||
for rows.Next() {
|
||||
var item domain.Tier
|
||||
if err := rows.Scan(
|
||||
&item.TierID,
|
||||
&item.TierCode,
|
||||
&item.TierName,
|
||||
&item.MinCoinAmount,
|
||||
&item.MaxCoinAmount,
|
||||
&item.ResourceGroupID,
|
||||
&item.Status,
|
||||
&item.SortOrder,
|
||||
&item.CreatedAtMS,
|
||||
&item.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func scanFirstRechargeRewardClaims(rows *sql.Rows, total int64) ([]domain.Claim, int64, error) {
|
||||
items := make([]domain.Claim, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanFirstRechargeRewardClaim(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func scanFirstRechargeRewardClaim(row rowScanner) (domain.Claim, error) {
|
||||
var claim domain.Claim
|
||||
err := row.Scan(
|
||||
&claim.ClaimID,
|
||||
&claim.EventID,
|
||||
&claim.TransactionID,
|
||||
&claim.CommandID,
|
||||
&claim.UserID,
|
||||
&claim.TierID,
|
||||
&claim.TierCode,
|
||||
&claim.ResourceGroupID,
|
||||
&claim.RechargeCoinAmount,
|
||||
&claim.RechargeSequence,
|
||||
&claim.RechargeType,
|
||||
&claim.Status,
|
||||
&claim.WalletCommandID,
|
||||
&claim.WalletGrantID,
|
||||
&claim.FailureReason,
|
||||
&claim.RechargedAtMS,
|
||||
&claim.GrantedAtMS,
|
||||
&claim.CreatedAtMS,
|
||||
&claim.UpdatedAtMS,
|
||||
)
|
||||
return claim, err
|
||||
}
|
||||
|
||||
func firstRechargeRewardConfigSelectSQL() string {
|
||||
return `SELECT app_code, enabled, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
FROM first_recharge_reward_configs `
|
||||
}
|
||||
|
||||
func firstRechargeRewardTierSelectSQL() string {
|
||||
return `SELECT tier_id, tier_code, tier_name, min_coin_amount, max_coin_amount,
|
||||
resource_group_id, status, sort_order, created_at_ms, updated_at_ms
|
||||
FROM first_recharge_reward_tiers `
|
||||
}
|
||||
|
||||
func firstRechargeRewardClaimSelectSQL() string {
|
||||
return `SELECT claim_id, event_id, transaction_id, command_id, user_id, tier_id, tier_code,
|
||||
resource_group_id, recharge_coin_amount, recharge_sequence, recharge_type,
|
||||
status, wallet_command_id, wallet_grant_id, failure_reason, recharged_at_ms,
|
||||
granted_at_ms, created_at_ms, updated_at_ms
|
||||
FROM first_recharge_reward_claims `
|
||||
}
|
||||
|
||||
func firstRechargeRewardClaimWhere(ctx context.Context, query domain.ClaimQuery) (string, []any) {
|
||||
conditions := []string{"app_code = ?"}
|
||||
args := []any{appcode.FromContext(ctx)}
|
||||
if query.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.UserID > 0 {
|
||||
conditions = append(conditions, "user_id = ?")
|
||||
args = append(args, query.UserID)
|
||||
}
|
||||
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
func matchFirstRechargeRewardTier(tiers []domain.Tier, coinAmount int64) (domain.Tier, bool) {
|
||||
for _, tier := range tiers {
|
||||
if tier.Status != domain.TierStatusActive || tier.ResourceGroupID <= 0 || tier.MinCoinAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
if coinAmount < tier.MinCoinAmount {
|
||||
continue
|
||||
}
|
||||
if tier.MaxCoinAmount > 0 && coinAmount > tier.MaxCoinAmount {
|
||||
continue
|
||||
}
|
||||
return tier, true
|
||||
}
|
||||
return domain.Tier{}, false
|
||||
}
|
||||
|
||||
func walletFirstRechargeRewardCommandID(claimID string) string {
|
||||
return fmt.Sprintf("wfr_%s", claimID)
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||
)
|
||||
|
||||
// WalletRechargeOutboxReader 只读 wallet-service outbox,不反向修改钱包事实状态。
|
||||
type WalletRechargeOutboxReader struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewWalletRechargeOutboxReader(db *sql.DB) *WalletRechargeOutboxReader {
|
||||
return &WalletRechargeOutboxReader{db: db}
|
||||
}
|
||||
|
||||
// ListRechargeEventsAfter 按创建时间和 event_id 稳定读取成功充值事实。
|
||||
func (r *WalletRechargeOutboxReader) ListRechargeEventsAfter(ctx context.Context, cursor domain.WalletRechargeCursor, batchSize int) ([]domain.RechargeEvent, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "wallet outbox reader is not configured")
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 100
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id,
|
||||
available_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
|
||||
FROM wallet_outbox
|
||||
WHERE event_type = ?
|
||||
AND (created_at_ms > ? OR (created_at_ms = ? AND event_id > ?))
|
||||
ORDER BY created_at_ms ASC, event_id ASC
|
||||
LIMIT ?`,
|
||||
domain.RechargeEventWalletRecorded, cursor.CreatedAtMS, cursor.CreatedAtMS, cursor.EventID, batchSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]domain.RechargeEvent, 0, batchSize)
|
||||
for rows.Next() {
|
||||
var event domain.RechargeEvent
|
||||
if err := rows.Scan(
|
||||
&event.AppCode,
|
||||
&event.EventID,
|
||||
&event.EventType,
|
||||
&event.TransactionID,
|
||||
&event.CommandID,
|
||||
&event.UserID,
|
||||
&event.RechargeCoinAmount,
|
||||
&event.PayloadJSON,
|
||||
&event.OccurredAtMS,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event.AppCode = appcode.Normalize(event.AppCode)
|
||||
event.RechargeSequence, event.RechargeType = rechargeFactFromPayload(event.PayloadJSON)
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func rechargeFactFromPayload(payload string) (int64, string) {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||
return 0, domain.RechargeTypeCoinSeller
|
||||
}
|
||||
sequence := int64(0)
|
||||
switch value := decoded["recharge_sequence"].(type) {
|
||||
case float64:
|
||||
sequence = int64(value)
|
||||
case int64:
|
||||
sequence = value
|
||||
case json.Number:
|
||||
sequence, _ = value.Int64()
|
||||
}
|
||||
rechargeType := domain.RechargeTypeCoinSeller
|
||||
if value, ok := decoded["recharge_type"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
rechargeType = strings.TrimSpace(value)
|
||||
}
|
||||
return sequence, rechargeType
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
domain "hyapp/services/activity-service/internal/domain/firstrecharge"
|
||||
service "hyapp/services/activity-service/internal/service/firstrecharge"
|
||||
)
|
||||
|
||||
// FirstRechargeRewardServer 暴露 App 首冲奖励查询和内部充值事实消费入口。
|
||||
type FirstRechargeRewardServer struct {
|
||||
activityv1.UnimplementedFirstRechargeRewardServiceServer
|
||||
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewFirstRechargeRewardServer(svc *service.Service) *FirstRechargeRewardServer {
|
||||
return &FirstRechargeRewardServer{svc: svc}
|
||||
}
|
||||
|
||||
func (s *FirstRechargeRewardServer) GetFirstRechargeRewardStatus(ctx context.Context, req *activityv1.GetFirstRechargeRewardStatusRequest) (*activityv1.GetFirstRechargeRewardStatusResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
status, err := s.svc.GetStatus(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.GetFirstRechargeRewardStatusResponse{Status: firstRechargeRewardStatusToProto(status)}, nil
|
||||
}
|
||||
|
||||
func (s *FirstRechargeRewardServer) ConsumeFirstRechargeReward(ctx context.Context, req *activityv1.ConsumeFirstRechargeRewardRequest) (*activityv1.ConsumeFirstRechargeRewardResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
claim, consumed, reason, err := s.svc.Consume(ctx, domain.RechargeEvent{
|
||||
AppCode: req.GetMeta().GetAppCode(),
|
||||
EventID: req.GetEventId(),
|
||||
TransactionID: req.GetTransactionId(),
|
||||
CommandID: req.GetCommandId(),
|
||||
UserID: req.GetUserId(),
|
||||
RechargeCoinAmount: req.GetRechargeCoinAmount(),
|
||||
RechargeSequence: req.GetRechargeSequence(),
|
||||
RechargeType: req.GetRechargeType(),
|
||||
OccurredAtMS: req.GetOccurredAtMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.ConsumeFirstRechargeRewardResponse{
|
||||
Claim: firstRechargeRewardClaimToProto(claim),
|
||||
Consumed: consumed,
|
||||
Reason: reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminFirstRechargeRewardServer 暴露后台配置和发放记录查询入口。
|
||||
type AdminFirstRechargeRewardServer struct {
|
||||
activityv1.UnimplementedAdminFirstRechargeRewardServiceServer
|
||||
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewAdminFirstRechargeRewardServer(svc *service.Service) *AdminFirstRechargeRewardServer {
|
||||
return &AdminFirstRechargeRewardServer{svc: svc}
|
||||
}
|
||||
|
||||
func (s *AdminFirstRechargeRewardServer) GetFirstRechargeRewardConfig(ctx context.Context, req *activityv1.GetFirstRechargeRewardConfigRequest) (*activityv1.GetFirstRechargeRewardConfigResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
config, err := s.svc.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.GetFirstRechargeRewardConfigResponse{Config: firstRechargeRewardConfigToProto(config)}, nil
|
||||
}
|
||||
|
||||
func (s *AdminFirstRechargeRewardServer) UpdateFirstRechargeRewardConfig(ctx context.Context, req *activityv1.UpdateFirstRechargeRewardConfigRequest) (*activityv1.UpdateFirstRechargeRewardConfigResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
tiers := make([]domain.Tier, 0, len(req.GetTiers()))
|
||||
for _, tier := range req.GetTiers() {
|
||||
tiers = append(tiers, domain.Tier{
|
||||
TierID: tier.GetTierId(),
|
||||
TierCode: tier.GetTierCode(),
|
||||
TierName: tier.GetTierName(),
|
||||
MinCoinAmount: tier.GetMinCoinAmount(),
|
||||
MaxCoinAmount: tier.GetMaxCoinAmount(),
|
||||
ResourceGroupID: tier.GetResourceGroupId(),
|
||||
Status: tier.GetStatus(),
|
||||
SortOrder: tier.GetSortOrder(),
|
||||
})
|
||||
}
|
||||
config, err := s.svc.UpdateConfig(ctx, domain.Config{
|
||||
Enabled: req.GetEnabled(),
|
||||
Tiers: tiers,
|
||||
UpdatedByAdminID: req.GetOperatorAdminId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &activityv1.UpdateFirstRechargeRewardConfigResponse{Config: firstRechargeRewardConfigToProto(config)}, nil
|
||||
}
|
||||
|
||||
func (s *AdminFirstRechargeRewardServer) ListFirstRechargeRewardClaims(ctx context.Context, req *activityv1.ListFirstRechargeRewardClaimsRequest) (*activityv1.ListFirstRechargeRewardClaimsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
claims, total, err := s.svc.ListClaims(ctx, domain.ClaimQuery{
|
||||
Status: req.GetStatus(),
|
||||
UserID: req.GetUserId(),
|
||||
Page: req.GetPage(),
|
||||
PageSize: req.GetPageSize(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &activityv1.ListFirstRechargeRewardClaimsResponse{Claims: make([]*activityv1.FirstRechargeRewardClaim, 0, len(claims)), Total: total}
|
||||
for _, claim := range claims {
|
||||
resp.Claims = append(resp.Claims, firstRechargeRewardClaimToProto(claim))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func firstRechargeRewardStatusToProto(status domain.StatusResult) *activityv1.FirstRechargeRewardStatus {
|
||||
resp := &activityv1.FirstRechargeRewardStatus{
|
||||
Enabled: status.Enabled,
|
||||
Tiers: make([]*activityv1.FirstRechargeRewardTier, 0, len(status.Tiers)),
|
||||
Rewarded: status.Rewarded,
|
||||
Claim: firstRechargeRewardClaimToProto(status.Claim),
|
||||
ServerTimeMs: status.ServerTimeMS,
|
||||
}
|
||||
for _, tier := range status.Tiers {
|
||||
resp.Tiers = append(resp.Tiers, firstRechargeRewardTierToProto(tier))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func firstRechargeRewardConfigToProto(config domain.Config) *activityv1.FirstRechargeRewardConfig {
|
||||
resp := &activityv1.FirstRechargeRewardConfig{
|
||||
AppCode: config.AppCode,
|
||||
Enabled: config.Enabled,
|
||||
Tiers: make([]*activityv1.FirstRechargeRewardTier, 0, len(config.Tiers)),
|
||||
UpdatedByAdminId: config.UpdatedByAdminID,
|
||||
CreatedAtMs: config.CreatedAtMS,
|
||||
UpdatedAtMs: config.UpdatedAtMS,
|
||||
}
|
||||
for _, tier := range config.Tiers {
|
||||
resp.Tiers = append(resp.Tiers, firstRechargeRewardTierToProto(tier))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func firstRechargeRewardTierToProto(tier domain.Tier) *activityv1.FirstRechargeRewardTier {
|
||||
return &activityv1.FirstRechargeRewardTier{
|
||||
TierId: tier.TierID,
|
||||
TierCode: tier.TierCode,
|
||||
TierName: tier.TierName,
|
||||
MinCoinAmount: tier.MinCoinAmount,
|
||||
MaxCoinAmount: tier.MaxCoinAmount,
|
||||
ResourceGroupId: tier.ResourceGroupID,
|
||||
Status: tier.Status,
|
||||
SortOrder: tier.SortOrder,
|
||||
CreatedAtMs: tier.CreatedAtMS,
|
||||
UpdatedAtMs: tier.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func firstRechargeRewardClaimToProto(claim domain.Claim) *activityv1.FirstRechargeRewardClaim {
|
||||
if claim.ClaimID == "" {
|
||||
return nil
|
||||
}
|
||||
return &activityv1.FirstRechargeRewardClaim{
|
||||
ClaimId: claim.ClaimID,
|
||||
EventId: claim.EventID,
|
||||
TransactionId: claim.TransactionID,
|
||||
CommandId: claim.CommandID,
|
||||
UserId: claim.UserID,
|
||||
TierId: claim.TierID,
|
||||
TierCode: claim.TierCode,
|
||||
ResourceGroupId: claim.ResourceGroupID,
|
||||
RechargeCoinAmount: claim.RechargeCoinAmount,
|
||||
RechargeSequence: claim.RechargeSequence,
|
||||
RechargeType: claim.RechargeType,
|
||||
Status: claim.Status,
|
||||
WalletCommandId: claim.WalletCommandID,
|
||||
WalletGrantId: claim.WalletGrantID,
|
||||
FailureReason: claim.FailureReason,
|
||||
RechargedAtMs: claim.RechargedAtMS,
|
||||
GrantedAtMs: claim.GrantedAtMS,
|
||||
CreatedAtMs: claim.CreatedAtMS,
|
||||
UpdatedAtMs: claim.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ const (
|
||||
AdapterDemo = "demo"
|
||||
AdapterYomiV4 = "yomi_v4"
|
||||
AdapterLeaderCCV1 = "leadercc_v1"
|
||||
AdapterZeeOneV1 = "zeeone_v1"
|
||||
|
||||
OrderStatusWalletApplying = "wallet_applying"
|
||||
OrderStatusSucceeded = "succeeded"
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
)
|
||||
|
||||
func (s *Service) appSessionFromAccessToken(app string, token string) (gamedomain.LaunchSession, error) {
|
||||
// ZeeOne/灵仙都直接把 App access token 作为厂商 sessionId/token;game-service 不持有 JWT secret,
|
||||
// 这里只消费 token 内部用户快照并校验 exp/app_code,入口签名仍由厂商 AppSecret 负责。
|
||||
claims := jwt.MapClaims{}
|
||||
_, _, err := new(jwt.Parser).ParseUnverified(strings.TrimSpace(token), claims)
|
||||
if err != nil {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
if typ := strings.TrimSpace(jwtStringClaim(claims, "typ")); typ != "" && typ != "access" {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
expiresAt, err := claims.GetExpirationTime()
|
||||
if err != nil || expiresAt == nil || !expiresAt.After(s.now()) {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
|
||||
}
|
||||
tokenApp := appcode.Normalize(jwtStringClaim(claims, "app_code"))
|
||||
if tokenApp != "" && tokenApp != appcode.Normalize(app) {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(jwtStringClaim(claims, "user_id")), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
displayUserID := firstNonEmpty(
|
||||
jwtStringClaim(claims, "display_user_id"),
|
||||
jwtStringClaim(claims, "default_display_user_id"),
|
||||
strconv.FormatInt(userID, 10),
|
||||
)
|
||||
return gamedomain.LaunchSession{
|
||||
AppCode: tokenApp,
|
||||
SessionID: jwtStringClaim(claims, "sid"),
|
||||
UserID: userID,
|
||||
DisplayUserID: displayUserID,
|
||||
Status: gamedomain.SessionActive,
|
||||
ExpiresAtMS: expiresAt.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jwtStringClaim(claims jwt.MapClaims, key string) string {
|
||||
value, _ := claims[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@ -9,9 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
)
|
||||
@ -239,7 +237,7 @@ func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, pla
|
||||
func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
|
||||
// 灵仙回调直接使用 App 登录 access token;无需预先进入游戏创建 launch session。
|
||||
if strings.Count(strings.TrimSpace(token), ".") == 2 {
|
||||
return s.leaderccSessionFromAppToken(app, token)
|
||||
return s.appSessionFromAccessToken(app, token)
|
||||
}
|
||||
// 兼容历史联调 token:老链路会先 launch,再按 game_launch_sessions 校验。
|
||||
session, err := s.validYomiSession(ctx, app, token)
|
||||
@ -260,42 +258,6 @@ func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAda
|
||||
normalizedUID == strconv.FormatInt(session.UserID, 10)
|
||||
}
|
||||
|
||||
func (s *Service) leaderccSessionFromAppToken(app string, token string) (gamedomain.LaunchSession, error) {
|
||||
claims := jwt.MapClaims{}
|
||||
_, _, err := new(jwt.Parser).ParseUnverified(strings.TrimSpace(token), claims)
|
||||
if err != nil {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
if typ := strings.TrimSpace(leaderccStringClaim(claims, "typ")); typ != "" && typ != "access" {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
expiresAt, err := claims.GetExpirationTime()
|
||||
if err != nil || expiresAt == nil || !expiresAt.After(s.now()) {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
|
||||
}
|
||||
tokenApp := appcode.Normalize(leaderccStringClaim(claims, "app_code"))
|
||||
if tokenApp != "" && tokenApp != appcode.Normalize(app) {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
userID, err := strconv.ParseInt(strings.TrimSpace(leaderccStringClaim(claims, "user_id")), 10, 64)
|
||||
if err != nil || userID <= 0 {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
|
||||
}
|
||||
displayUserID := firstNonEmpty(
|
||||
leaderccStringClaim(claims, "display_user_id"),
|
||||
leaderccStringClaim(claims, "default_display_user_id"),
|
||||
strconv.FormatInt(userID, 10),
|
||||
)
|
||||
return gamedomain.LaunchSession{
|
||||
AppCode: tokenApp,
|
||||
SessionID: leaderccStringClaim(claims, "sid"),
|
||||
UserID: userID,
|
||||
DisplayUserID: displayUserID,
|
||||
Status: gamedomain.SessionActive,
|
||||
ExpiresAtMS: expiresAt.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func leaderccOpType(value int32) string {
|
||||
// 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。
|
||||
switch value {
|
||||
@ -344,11 +306,6 @@ func leaderccTokenOnlyProbe(token string, parts ...string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func leaderccStringClaim(claims jwt.MapClaims, key string) string {
|
||||
value, _ := claims[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||||
// signatureValid/providerRequestID 会写入 callback log,帮助后续排查签名和重复订单问题。
|
||||
raw, contentType := leaderccJSON(code, message, map[string]any{})
|
||||
|
||||
@ -186,7 +186,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
||||
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||
token := strings.TrimSpace(command.AccessToken)
|
||||
if token == "" {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||
return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
|
||||
}
|
||||
token = "gt_" + randomHex(24)
|
||||
@ -197,6 +197,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
|
||||
sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL())
|
||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
|
||||
sessionTTL = maxDuration(sessionTTL, leaderccConfigFromPlatform(game).TokenTTL())
|
||||
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||
sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
|
||||
}
|
||||
expiresAt := now.Add(sessionTTL).UnixMilli()
|
||||
session := gamedomain.LaunchSession{
|
||||
@ -261,6 +263,8 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash)
|
||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1):
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleLeaderCCOperation(ctx, app, platform, req, operation, requestHash)
|
||||
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterZeeOneV1):
|
||||
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZeeOneOperation(ctx, app, platform, req, operation, requestHash)
|
||||
default:
|
||||
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
|
||||
}
|
||||
@ -553,7 +557,16 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
// 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。
|
||||
return "", nil
|
||||
}
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
|
||||
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
|
||||
base = configuredBase
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||
return "", xerr.New(xerr.InvalidArgument, "zeeone launch url is required")
|
||||
}
|
||||
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
|
||||
base = "https://game.example.local/h5"
|
||||
}
|
||||
@ -601,6 +614,33 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
|
||||
// ZeeOne 直接加载指定游戏 H5,sessionId 使用 App access token,merchant/platform 来自后台热配置。
|
||||
config := zeeoneConfigFromPlatform(game)
|
||||
lang := config.DefaultLang
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
query.Set("sessionId", token)
|
||||
query.Set("language", lang)
|
||||
if config.MerchantID > 0 {
|
||||
query.Set("merchant", strconv.FormatInt(config.MerchantID, 10))
|
||||
}
|
||||
if config.PlatformID > 0 {
|
||||
query.Set("platform", strconv.FormatInt(config.PlatformID, 10))
|
||||
}
|
||||
if session.RoomID != "" {
|
||||
query.Set("sid", session.RoomID)
|
||||
}
|
||||
if config.GameMode != "" {
|
||||
query.Set("gameMode", config.GameMode)
|
||||
}
|
||||
if config.Extra != "" {
|
||||
query.Set("extra", config.Extra)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
// demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
|
||||
query.Set("session_id", session.SessionID)
|
||||
query.Set("session_token", token)
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -150,6 +151,81 @@ func TestLaunchGameBuildsLeaderCCURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{
|
||||
CatalogItem: gamedomain.CatalogItem{
|
||||
AppCode: "lalu",
|
||||
GameID: "zeeone_gold_olympus",
|
||||
PlatformCode: "zeeone",
|
||||
ProviderGameID: "1021",
|
||||
GameName: "Gold of Olympus",
|
||||
Status: gamedomain.StatusActive,
|
||||
Orientation: "portrait",
|
||||
},
|
||||
PlatformStatus: gamedomain.StatusActive,
|
||||
APIBaseURL: "https://fallback.example.invalid/game.html",
|
||||
AdapterType: gamedomain.AdapterZeeOneV1,
|
||||
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"default_lang":"en","token_ttl_seconds":86400,"game_urls":{"1021":"https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?merchant=0&platform=0"}}`,
|
||||
},
|
||||
}
|
||||
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
|
||||
AppCode: "lalu",
|
||||
UserID: 42,
|
||||
DisplayUserID: "420001",
|
||||
GameID: "zeeone_gold_olympus",
|
||||
RoomID: "01",
|
||||
AccessToken: "app_access_token_zeeone",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchGame failed: %v", err)
|
||||
}
|
||||
if result.ExpiresAtMS != 1700086400000 {
|
||||
t.Fatalf("zeeone launch must use adapter token ttl, got %+v", result)
|
||||
}
|
||||
if !strings.HasPrefix(result.LaunchURL, "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?") {
|
||||
t.Fatalf("zeeone launch url must use per-game configured url: %s", result.LaunchURL)
|
||||
}
|
||||
for _, part := range []string{"sessionId=app_access_token_zeeone", "language=en", "merchant=307715", "platform=2032", "sid=01"} {
|
||||
if !strings.Contains(result.LaunchURL, part) {
|
||||
t.Fatalf("zeeone launch url missing %s: %s", part, result.LaunchURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameRequiresZeeOneLaunchURL(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{
|
||||
CatalogItem: gamedomain.CatalogItem{
|
||||
AppCode: "lalu",
|
||||
GameID: "zeeone_unknown",
|
||||
PlatformCode: "zeeone",
|
||||
ProviderGameID: "9999",
|
||||
GameName: "Unknown ZeeOne Game",
|
||||
Status: gamedomain.StatusActive,
|
||||
},
|
||||
PlatformStatus: gamedomain.StatusActive,
|
||||
AdapterType: gamedomain.AdapterZeeOneV1,
|
||||
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032}`,
|
||||
},
|
||||
}
|
||||
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
_, err := svc.LaunchGame(context.Background(), LaunchCommand{
|
||||
AppCode: "lalu",
|
||||
UserID: 42,
|
||||
GameID: "zeeone_unknown",
|
||||
AccessToken: "app_access_token_zeeone",
|
||||
})
|
||||
if err == nil || !strings.Contains(xerr.MessageOf(err), "zeeone launch url is required") {
|
||||
t.Fatalf("LaunchGame must reject missing zeeone launch url, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchGameAllowsLeaderCCWithoutLaunchURL(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{
|
||||
@ -475,6 +551,71 @@ func TestHandleYomiRepairOrderOnlyLogs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleZeeOneUserInfoChangeBalanceAndUpdateSession(t *testing.T) {
|
||||
secret := "zeeone-test-secret"
|
||||
token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
|
||||
repo := &fakeRepository{
|
||||
platform: gamedomain.Platform{
|
||||
PlatformCode: "zeeone",
|
||||
AdapterType: gamedomain.AdapterZeeOneV1,
|
||||
CallbackSecretCiphertext: secret,
|
||||
AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
|
||||
},
|
||||
}
|
||||
wallet := &fakeWallet{balanceAfter: 1880}
|
||||
user := &fakeUser{}
|
||||
svc := New(Config{}, repo, wallet, user)
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
userBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021,"extra":"{\"vip\":1}"}`
|
||||
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-user", AppCode: "lalu"},
|
||||
PlatformCode: "zeeone",
|
||||
Operation: "get_user_info",
|
||||
RawBody: zeeoneTestEnvelope(t, secret, userBody, 1700000000000),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback userinfo failed: %v", err)
|
||||
}
|
||||
if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) || !strings.Contains(string(raw), `"sessionId"`) {
|
||||
t.Fatalf("zeeone userinfo response mismatch: %s", raw)
|
||||
}
|
||||
if user.lastGet.GetUserId() != 42 {
|
||||
t.Fatalf("zeeone userinfo must query token user, got %+v", user.lastGet)
|
||||
}
|
||||
|
||||
changeBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","currency_diff":-120,"diff_msg":"bet","game_id":1021,"room_id":"01","change_time_at":1700000000,"order_id":"zee_order_1","extend":"round_1"}`
|
||||
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-change", AppCode: "lalu"},
|
||||
PlatformCode: "zeeone",
|
||||
Operation: "change_balance",
|
||||
RawBody: zeeoneTestEnvelope(t, secret, changeBody, 1700000000000),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback change failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"currency_balance":1880`) {
|
||||
t.Fatalf("zeeone change response mismatch: %s", raw)
|
||||
}
|
||||
if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "1021" {
|
||||
t.Fatalf("zeeone wallet command mismatch: %+v", wallet.lastApply)
|
||||
}
|
||||
|
||||
updateBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021}`
|
||||
raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-zeeone-update-session", AppCode: "lalu"},
|
||||
PlatformCode: "zeeone",
|
||||
Operation: "update_session",
|
||||
RawBody: zeeoneTestEnvelope(t, secret, updateBody, 1700000000000),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback update_session failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expireDate":1700003600000`) {
|
||||
t.Fatalf("zeeone update_session response mismatch: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
|
||||
@ -771,3 +912,16 @@ func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUser
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
func zeeoneTestEnvelope(t *testing.T, secret string, body string, timestamp int64) []byte {
|
||||
t.Helper()
|
||||
nonce := "5f0eb04d7603a9d8"
|
||||
raw := nonce + body + strconv.FormatInt(timestamp, 10) + secret
|
||||
sum := md5.Sum([]byte(raw))
|
||||
signature := hex.EncodeToString(sum[:])
|
||||
encodedBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal reqbody: %v", err)
|
||||
}
|
||||
return []byte(`{"reqbody":` + string(encodedBody) + `,"signature":"` + signature + `","signature_nonce":"` + nonce + `","timestamp":` + strconv.FormatInt(timestamp, 10) + `}`)
|
||||
}
|
||||
|
||||
408
services/game-service/internal/service/game/zeeone_adapter.go
Normal file
408
services/game-service/internal/service/game/zeeone_adapter.go
Normal file
@ -0,0 +1,408 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gamev1 "hyapp.local/api/proto/game/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||||
)
|
||||
|
||||
const (
|
||||
// ZeeOne 文档定义 code=0 成功;HTTP 200 不代表业务成功,游戏侧读取 JSON code。
|
||||
zeeoneCodeOK = 0
|
||||
zeeoneCodeInvalidArgument = 1
|
||||
zeeoneCodeSessionNotFound = 2
|
||||
zeeoneCodeSignatureInvalid = 3
|
||||
zeeoneCodeSessionInvalid = 5
|
||||
zeeoneCodeInsufficientBalance = 6
|
||||
|
||||
zeeoneDefaultTokenTTL = 24 * time.Hour
|
||||
zeeoneSignatureValidWindow = 15 * time.Second
|
||||
zeeoneDefaultSuccessMessage = "succeed"
|
||||
)
|
||||
|
||||
type zeeoneAdapterConfig struct {
|
||||
// merchant_id/platform_id 来自 ZeeOne 商户后台;启动 URL 和 appId 校验都依赖这两个值。
|
||||
MerchantID int64 `json:"merchant_id"`
|
||||
PlatformID int64 `json:"platform_id"`
|
||||
PlatformIDAlias int64 `json:"platform"`
|
||||
AppID string `json:"app_id"`
|
||||
UIDMode string `json:"uid_mode"`
|
||||
DefaultLang string `json:"default_lang"`
|
||||
TokenTTLSeconds int64 `json:"token_ttl_seconds"`
|
||||
GameMode string `json:"game_mode"`
|
||||
Extra string `json:"extra"`
|
||||
// game_urls 解决同一个 ZeeOne 商户下每款游戏 H5 域名/路径不同的问题;后台改 JSON 后下一次启动即生效。
|
||||
GameURLs map[string]string `json:"game_urls"`
|
||||
// launch_url_template 适合 ZeeOne 后续按固定路径批量上新,支持 {provider_game_id}/{providerGameId}/{game_id}/{gameId} 占位。
|
||||
LaunchURLTemplate string `json:"launch_url_template"`
|
||||
}
|
||||
|
||||
func zeeoneConfigFromPlatform(value any) zeeoneAdapterConfig {
|
||||
var raw string
|
||||
switch typed := value.(type) {
|
||||
case gamedomain.Platform:
|
||||
raw = typed.AdapterConfigJSON
|
||||
case gamedomain.LaunchableGame:
|
||||
raw = typed.AdapterConfigJSON
|
||||
}
|
||||
var config zeeoneAdapterConfig
|
||||
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
|
||||
if config.PlatformID == 0 {
|
||||
config.PlatformID = config.PlatformIDAlias
|
||||
}
|
||||
config.AppID = strings.TrimSpace(config.AppID)
|
||||
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
|
||||
config.DefaultLang = strings.TrimSpace(config.DefaultLang)
|
||||
config.GameMode = strings.TrimSpace(config.GameMode)
|
||||
config.Extra = strings.TrimSpace(config.Extra)
|
||||
config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
|
||||
config.GameURLs = normalizeStringMap(config.GameURLs)
|
||||
return config
|
||||
}
|
||||
|
||||
func (c zeeoneAdapterConfig) TokenTTL() time.Duration {
|
||||
if c.TokenTTLSeconds > 0 {
|
||||
return time.Duration(c.TokenTTLSeconds) * time.Second
|
||||
}
|
||||
return zeeoneDefaultTokenTTL
|
||||
}
|
||||
|
||||
func (c zeeoneAdapterConfig) ExpectedAppID() string {
|
||||
if c.AppID != "" {
|
||||
return c.AppID
|
||||
}
|
||||
if c.MerchantID > 0 && c.PlatformID > 0 {
|
||||
return fmt.Sprintf("M%d_P%d", c.MerchantID, c.PlatformID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func zeeoneLaunchBaseURL(game gamedomain.LaunchableGame, config zeeoneAdapterConfig) string {
|
||||
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
|
||||
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if config.LaunchURLTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
replacer := strings.NewReplacer(
|
||||
"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
|
||||
"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
|
||||
"{game_id}", strings.TrimSpace(game.GameID),
|
||||
"{gameId}", strings.TrimSpace(game.GameID),
|
||||
)
|
||||
return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
|
||||
}
|
||||
|
||||
func normalizeStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key != "" && value != "" {
|
||||
output[key] = value
|
||||
}
|
||||
}
|
||||
if len(output) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func (s *Service) handleZeeOneOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||
config := zeeoneConfigFromPlatform(platform)
|
||||
body, signed, decoded, providerRequestID := s.decodeZeeOneSignedBody(platform, req)
|
||||
if !signed {
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeSignatureInvalid, "signature invalid", map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeSignatureInvalid), false, providerRequestID, nil
|
||||
}
|
||||
if !decoded {
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid argument", map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
|
||||
}
|
||||
if err := validateZeeOneAppID(config, body.AppID); err != nil {
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid appId", map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
|
||||
}
|
||||
switch operation {
|
||||
case "get_user_info", "userinfo", "user_info":
|
||||
return s.handleZeeOneUserInfo(ctx, app, config, req, body)
|
||||
case "change_balance", "change_coin", "update_coin":
|
||||
return s.handleZeeOneChangeBalance(ctx, app, config, req, body, requestHash)
|
||||
case "update_session", "refresh_session":
|
||||
return s.handleZeeOneUpdateSession(ctx, app, config, body)
|
||||
case "repair_order", "repair":
|
||||
return s.handleZeeOneRepairOrder(ctx, app, platform, body)
|
||||
default:
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), signed, providerRequestID, nil
|
||||
}
|
||||
}
|
||||
|
||||
type zeeoneSignedEnvelope struct {
|
||||
ReqBody string `json:"reqbody"`
|
||||
Signature string `json:"signature"`
|
||||
SignatureNonce string `json:"signature_nonce"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type zeeoneBusinessBody struct {
|
||||
AppID string `json:"appId"`
|
||||
UserID string `json:"userId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Extra string `json:"extra"`
|
||||
CurrencyDiff int64 `json:"currency_diff"`
|
||||
DiffMsg string `json:"diff_msg"`
|
||||
GameID int64 `json:"game_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
ChangeTimeAt int64 `json:"change_time_at"`
|
||||
OrderID string `json:"order_id"`
|
||||
Extend string `json:"extend"`
|
||||
}
|
||||
|
||||
func (s *Service) decodeZeeOneSignedBody(platform gamedomain.Platform, req *gamev1.CallbackRequest) (zeeoneBusinessBody, bool, bool, string) {
|
||||
var envelope zeeoneSignedEnvelope
|
||||
if err := json.Unmarshal(req.GetRawBody(), &envelope); err != nil {
|
||||
return zeeoneBusinessBody{}, false, false, ""
|
||||
}
|
||||
envelope.ReqBody = strings.TrimSpace(envelope.ReqBody)
|
||||
providerRequestID := zeeoneProviderRequestID(envelope.ReqBody)
|
||||
if !s.zeeoneSignatureValid(envelope, platform.CallbackSecretCiphertext) {
|
||||
return zeeoneBusinessBody{}, false, false, providerRequestID
|
||||
}
|
||||
var body zeeoneBusinessBody
|
||||
decoder := json.NewDecoder(strings.NewReader(envelope.ReqBody))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
return zeeoneBusinessBody{}, false, true, providerRequestID
|
||||
}
|
||||
body.normalize()
|
||||
return body, true, true, firstNonEmpty(body.OrderID, providerRequestID)
|
||||
}
|
||||
|
||||
func (s *Service) zeeoneSignatureValid(envelope zeeoneSignedEnvelope, secret string) bool {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" || envelope.ReqBody == "" || strings.TrimSpace(envelope.SignatureNonce) == "" || envelope.Timestamp <= 0 {
|
||||
return false
|
||||
}
|
||||
now := s.now()
|
||||
requestAt := time.UnixMilli(envelope.Timestamp)
|
||||
if requestAt.After(now.Add(zeeoneSignatureValidWindow)) || now.Sub(requestAt) > zeeoneSignatureValidWindow {
|
||||
return false
|
||||
}
|
||||
raw := fmt.Sprintf("%s%s%d%s", strings.TrimSpace(envelope.SignatureNonce), envelope.ReqBody, envelope.Timestamp, secret)
|
||||
sum := md5.Sum([]byte(raw))
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
return strings.EqualFold(strings.TrimSpace(envelope.Signature), expected)
|
||||
}
|
||||
|
||||
func (body *zeeoneBusinessBody) normalize() {
|
||||
body.AppID = strings.TrimSpace(body.AppID)
|
||||
body.UserID = strings.TrimSpace(body.UserID)
|
||||
body.SessionID = strings.TrimSpace(body.SessionID)
|
||||
body.Extra = strings.TrimSpace(body.Extra)
|
||||
body.DiffMsg = strings.ToLower(strings.TrimSpace(body.DiffMsg))
|
||||
body.RoomID = strings.TrimSpace(body.RoomID)
|
||||
body.OrderID = strings.TrimSpace(body.OrderID)
|
||||
body.Extend = strings.TrimSpace(body.Extend)
|
||||
}
|
||||
|
||||
func validateZeeOneAppID(config zeeoneAdapterConfig, appID string) error {
|
||||
expected := config.ExpectedAppID()
|
||||
if expected == "" || strings.TrimSpace(appID) == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(appID) != expected {
|
||||
return xerr.New(xerr.InvalidArgument, "appId mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleZeeOneUserInfo(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||||
if err != nil {
|
||||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||||
}
|
||||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
|
||||
}
|
||||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||
if err != nil {
|
||||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||||
}
|
||||
data := map[string]any{
|
||||
"userId": firstNonEmpty(body.UserID, externalUserID(session, config.UIDMode)),
|
||||
"name": snapshot.Nickname,
|
||||
"avatar": snapshot.Avatar,
|
||||
"balance": snapshot.Balance,
|
||||
"sessionId": body.SessionID,
|
||||
"expireDate": session.ExpiresAtMS,
|
||||
}
|
||||
if body.Extra != "" {
|
||||
data["extra"] = body.Extra
|
||||
}
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, data)
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
|
||||
}
|
||||
|
||||
func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody, requestHash string) ([]byte, string, string, bool, string, error) {
|
||||
if body.OrderID == "" || body.UserID == "" || body.SessionID == "" || body.GameID <= 0 || body.DiffMsg == "" {
|
||||
return zeeoneAdapterError(zeeoneCodeInvalidArgument, "invalid argument", true, body.OrderID)
|
||||
}
|
||||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||||
if err != nil {
|
||||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
|
||||
}
|
||||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, body.OrderID)
|
||||
}
|
||||
if session.ProviderGameID == "" {
|
||||
session.ProviderGameID = strconv.FormatInt(body.GameID, 10)
|
||||
}
|
||||
if session.GameID == "" {
|
||||
session.GameID = session.ProviderGameID
|
||||
}
|
||||
opType, amount := zeeoneOpType(body.CurrencyDiff)
|
||||
result, err := s.applyYomiCoinChange(ctx, app, req, session, body.OrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
|
||||
if err != nil {
|
||||
code := zeeoneCodeFromError(err)
|
||||
balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
|
||||
raw, contentType := zeeoneJSON(code, zeeoneMessageFromError(err), map[string]any{"currency_balance": balance})
|
||||
return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil
|
||||
}
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{"currency_balance": result.BalanceAfter})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleZeeOneUpdateSession(ctx context.Context, app string, config zeeoneAdapterConfig, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||||
session, err := s.validZeeOneSession(ctx, app, body.SessionID)
|
||||
if err != nil {
|
||||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
|
||||
}
|
||||
if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
|
||||
return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
|
||||
}
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{
|
||||
"sessionId": body.SessionID,
|
||||
"expireDate": session.ExpiresAtMS,
|
||||
})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
|
||||
}
|
||||
|
||||
func (s *Service) handleZeeOneRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
|
||||
if body.OrderID != "" {
|
||||
_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
|
||||
AppCode: app,
|
||||
RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+body.OrderID),
|
||||
PlatformCode: platform.PlatformCode,
|
||||
ProviderOrderID: body.OrderID,
|
||||
Reason: "zeeone_repair_order",
|
||||
Status: gamedomain.RepairStatusLogged,
|
||||
})
|
||||
if err != nil {
|
||||
return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
|
||||
}
|
||||
}
|
||||
raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
|
||||
}
|
||||
|
||||
func (s *Service) validZeeOneSession(ctx context.Context, app string, sessionID string) (gamedomain.LaunchSession, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
|
||||
}
|
||||
if session, err := s.validYomiSession(ctx, app, sessionID); err == nil {
|
||||
return session, nil
|
||||
}
|
||||
if strings.Count(sessionID, ".") == 2 {
|
||||
return s.appSessionFromAccessToken(app, sessionID)
|
||||
}
|
||||
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
|
||||
}
|
||||
|
||||
func zeeoneSessionMatches(session gamedomain.LaunchSession, config zeeoneAdapterConfig, userID string, gameID int64) bool {
|
||||
if gameID > 0 && strings.TrimSpace(session.ProviderGameID) != "" && strconv.FormatInt(gameID, 10) != strings.TrimSpace(session.ProviderGameID) {
|
||||
return false
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
return userID == "" ||
|
||||
userID == externalUserID(session, config.UIDMode) ||
|
||||
userID == strings.TrimSpace(session.DisplayUserID) ||
|
||||
userID == strconv.FormatInt(session.UserID, 10)
|
||||
}
|
||||
|
||||
func zeeoneOpType(diff int64) (string, int64) {
|
||||
if diff < 0 {
|
||||
return "debit", -diff
|
||||
}
|
||||
return "credit", diff
|
||||
}
|
||||
|
||||
func zeeoneProviderRequestID(reqBody string) string {
|
||||
var body struct {
|
||||
OrderID string `json:"order_id"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(reqBody), &body)
|
||||
return strings.TrimSpace(body.OrderID)
|
||||
}
|
||||
|
||||
func zeeoneAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
|
||||
raw, contentType := zeeoneJSON(code, message, map[string]any{})
|
||||
return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
|
||||
}
|
||||
|
||||
func zeeoneJSON(code int, message string, data any) ([]byte, string) {
|
||||
if strings.TrimSpace(message) == "" && code == zeeoneCodeOK {
|
||||
message = zeeoneDefaultSuccessMessage
|
||||
}
|
||||
payload := map[string]any{"code": code, "message": strings.TrimSpace(message)}
|
||||
if data != nil {
|
||||
payload["data"] = data
|
||||
}
|
||||
return jsonResponse(payload)
|
||||
}
|
||||
|
||||
func zeeoneCodeFromError(err error) int {
|
||||
switch {
|
||||
case err == nil:
|
||||
return zeeoneCodeOK
|
||||
case xerr.IsCode(err, xerr.InsufficientBalance):
|
||||
return zeeoneCodeInsufficientBalance
|
||||
case xerr.IsCode(err, xerr.SessionExpired):
|
||||
return zeeoneCodeSessionInvalid
|
||||
case xerr.IsCode(err, xerr.Unauthorized):
|
||||
return zeeoneCodeSessionNotFound
|
||||
default:
|
||||
return zeeoneCodeInvalidArgument
|
||||
}
|
||||
}
|
||||
|
||||
func zeeoneMessageFromError(err error) string {
|
||||
switch zeeoneCodeFromError(err) {
|
||||
case zeeoneCodeInsufficientBalance:
|
||||
return "coin not enough"
|
||||
case zeeoneCodeSessionNotFound:
|
||||
return "sessionId not found"
|
||||
case zeeoneCodeSessionInvalid:
|
||||
return "sessionId invalid"
|
||||
default:
|
||||
if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
|
||||
return xerr.MessageOf(err)
|
||||
}
|
||||
return "invalid argument"
|
||||
}
|
||||
}
|
||||
@ -400,6 +400,8 @@ func normalizeAdapterType(adapterType string) string {
|
||||
return gamedomain.AdapterYomiV4
|
||||
case gamedomain.AdapterLeaderCCV1:
|
||||
return gamedomain.AdapterLeaderCCV1
|
||||
case gamedomain.AdapterZeeOneV1:
|
||||
return gamedomain.AdapterZeeOneV1
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -97,6 +97,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
|
||||
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
|
||||
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
|
||||
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
|
||||
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
|
||||
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
|
||||
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
|
||||
@ -140,6 +141,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
handler.SetGrowthLevelClient(growthLevelClient)
|
||||
handler.SetAchievementClient(achievementClient)
|
||||
handler.SetRegistrationRewardClient(registrationRewardClient)
|
||||
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
|
||||
handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
|
||||
handler.SetBroadcastClient(broadcastClient)
|
||||
handler.SetGameClient(gameClient)
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
)
|
||||
|
||||
// FirstRechargeRewardClient abstracts gateway reads from activity-service first recharge reward APIs.
|
||||
type FirstRechargeRewardClient interface {
|
||||
GetFirstRechargeRewardStatus(ctx context.Context, req *activityv1.GetFirstRechargeRewardStatusRequest) (*activityv1.GetFirstRechargeRewardStatusResponse, error)
|
||||
}
|
||||
|
||||
type grpcFirstRechargeRewardClient struct {
|
||||
client activityv1.FirstRechargeRewardServiceClient
|
||||
}
|
||||
|
||||
func NewGRPCFirstRechargeRewardClient(conn *grpc.ClientConn) FirstRechargeRewardClient {
|
||||
return &grpcFirstRechargeRewardClient{client: activityv1.NewFirstRechargeRewardServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *grpcFirstRechargeRewardClient) GetFirstRechargeRewardStatus(ctx context.Context, req *activityv1.GetFirstRechargeRewardStatusRequest) (*activityv1.GetFirstRechargeRewardStatusResponse, error) {
|
||||
return c.client.GetFirstRechargeRewardStatus(ctx, req)
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
package activityapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type firstRechargeRewardTierData struct {
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
TierName string `json:"tier_name"`
|
||||
MinCoinAmount int64 `json:"min_coin_amount"`
|
||||
MaxCoinAmount int64 `json:"max_coin_amount"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type firstRechargeRewardClaimData struct {
|
||||
ClaimID string `json:"claim_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
TierID int64 `json:"tier_id"`
|
||||
TierCode string `json:"tier_code"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
RechargeCoinAmount int64 `json:"recharge_coin_amount"`
|
||||
RechargeSequence int64 `json:"recharge_sequence"`
|
||||
RechargeType string `json:"recharge_type"`
|
||||
Status string `json:"status"`
|
||||
WalletGrantID string `json:"wallet_grant_id"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
RechargedAtMS int64 `json:"recharged_at_ms"`
|
||||
GrantedAtMS int64 `json:"granted_at_ms"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type firstRechargeRewardStatusData struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Tiers []firstRechargeRewardTierData `json:"tiers"`
|
||||
Rewarded bool `json:"rewarded"`
|
||||
Claim *firstRechargeRewardClaimData `json:"claim,omitempty"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
func (h *Handler) getFirstRechargeRewardStatus(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.firstRechargeReward == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
resp, err := h.firstRechargeReward.GetFirstRechargeRewardStatus(request.Context(), &activityv1.GetFirstRechargeRewardStatusRequest{
|
||||
Meta: httpkit.ActivityMeta(request),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, firstRechargeRewardStatusFromProto(resp.GetStatus()))
|
||||
}
|
||||
|
||||
func firstRechargeRewardStatusFromProto(item *activityv1.FirstRechargeRewardStatus) firstRechargeRewardStatusData {
|
||||
if item == nil {
|
||||
return firstRechargeRewardStatusData{Tiers: []firstRechargeRewardTierData{}}
|
||||
}
|
||||
tiers := make([]firstRechargeRewardTierData, 0, len(item.GetTiers()))
|
||||
for _, tier := range item.GetTiers() {
|
||||
tiers = append(tiers, firstRechargeRewardTierFromProto(tier))
|
||||
}
|
||||
var claim *firstRechargeRewardClaimData
|
||||
if item.GetClaim() != nil {
|
||||
converted := firstRechargeRewardClaimFromProto(item.GetClaim())
|
||||
claim = &converted
|
||||
}
|
||||
return firstRechargeRewardStatusData{
|
||||
Enabled: item.GetEnabled(),
|
||||
Tiers: tiers,
|
||||
Rewarded: item.GetRewarded(),
|
||||
Claim: claim,
|
||||
ServerTimeMS: item.GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func firstRechargeRewardTierFromProto(item *activityv1.FirstRechargeRewardTier) firstRechargeRewardTierData {
|
||||
if item == nil {
|
||||
return firstRechargeRewardTierData{}
|
||||
}
|
||||
return firstRechargeRewardTierData{
|
||||
TierID: item.GetTierId(),
|
||||
TierCode: item.GetTierCode(),
|
||||
TierName: item.GetTierName(),
|
||||
MinCoinAmount: item.GetMinCoinAmount(),
|
||||
MaxCoinAmount: item.GetMaxCoinAmount(),
|
||||
ResourceGroupID: item.GetResourceGroupId(),
|
||||
Status: item.GetStatus(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
|
||||
func firstRechargeRewardClaimFromProto(item *activityv1.FirstRechargeRewardClaim) firstRechargeRewardClaimData {
|
||||
if item == nil {
|
||||
return firstRechargeRewardClaimData{}
|
||||
}
|
||||
return firstRechargeRewardClaimData{
|
||||
ClaimID: item.GetClaimId(),
|
||||
TransactionID: item.GetTransactionId(),
|
||||
UserID: item.GetUserId(),
|
||||
TierID: item.GetTierId(),
|
||||
TierCode: item.GetTierCode(),
|
||||
ResourceGroupID: item.GetResourceGroupId(),
|
||||
RechargeCoinAmount: item.GetRechargeCoinAmount(),
|
||||
RechargeSequence: item.GetRechargeSequence(),
|
||||
RechargeType: item.GetRechargeType(),
|
||||
Status: item.GetStatus(),
|
||||
WalletGrantID: item.GetWalletGrantId(),
|
||||
FailureReason: item.GetFailureReason(),
|
||||
RechargedAtMS: item.GetRechargedAtMs(),
|
||||
GrantedAtMS: item.GetGrantedAtMs(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
@ -10,37 +10,40 @@ import (
|
||||
// Handler owns activity-facing HTTP endpoints.
|
||||
// It keeps task and registration reward reads/writes behind activity-service, with gateway only adding auth context.
|
||||
type Handler struct {
|
||||
taskClient client.TaskClient
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
achievementClient client.AchievementClient
|
||||
userProfileClient client.UserProfileClient
|
||||
walletClient client.WalletClient
|
||||
walletDB *sql.DB
|
||||
registrationReward client.RegistrationRewardClient
|
||||
sevenDayCheckIn client.SevenDayCheckInClient
|
||||
taskClient client.TaskClient
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
achievementClient client.AchievementClient
|
||||
userProfileClient client.UserProfileClient
|
||||
walletClient client.WalletClient
|
||||
walletDB *sql.DB
|
||||
registrationReward client.RegistrationRewardClient
|
||||
firstRechargeReward client.FirstRechargeRewardClient
|
||||
sevenDayCheckIn client.SevenDayCheckInClient
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
TaskClient client.TaskClient
|
||||
GrowthLevelClient client.GrowthLevelClient
|
||||
AchievementClient client.AchievementClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
WalletClient client.WalletClient
|
||||
WalletDB *sql.DB
|
||||
RegistrationReward client.RegistrationRewardClient
|
||||
SevenDayCheckIn client.SevenDayCheckInClient
|
||||
TaskClient client.TaskClient
|
||||
GrowthLevelClient client.GrowthLevelClient
|
||||
AchievementClient client.AchievementClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
WalletClient client.WalletClient
|
||||
WalletDB *sql.DB
|
||||
RegistrationReward client.RegistrationRewardClient
|
||||
FirstRechargeReward client.FirstRechargeRewardClient
|
||||
SevenDayCheckIn client.SevenDayCheckInClient
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
return &Handler{
|
||||
taskClient: config.TaskClient,
|
||||
growthLevelClient: config.GrowthLevelClient,
|
||||
achievementClient: config.AchievementClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
walletClient: config.WalletClient,
|
||||
walletDB: config.WalletDB,
|
||||
registrationReward: config.RegistrationReward,
|
||||
sevenDayCheckIn: config.SevenDayCheckIn,
|
||||
taskClient: config.TaskClient,
|
||||
growthLevelClient: config.GrowthLevelClient,
|
||||
achievementClient: config.AchievementClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
walletClient: config.WalletClient,
|
||||
walletDB: config.WalletDB,
|
||||
registrationReward: config.RegistrationReward,
|
||||
firstRechargeReward: config.FirstRechargeReward,
|
||||
sevenDayCheckIn: config.SevenDayCheckIn,
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +52,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
|
||||
ListTaskTabs: h.listTaskTabs,
|
||||
ClaimTaskReward: h.claimTaskReward,
|
||||
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
|
||||
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
|
||||
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
|
||||
SignSevenDayCheckIn: h.signSevenDayCheckIn,
|
||||
ListUserLeaderboards: h.listUserLeaderboards,
|
||||
|
||||
@ -39,6 +39,7 @@ type Handler struct {
|
||||
growthLevelClient client.GrowthLevelClient
|
||||
achievementClient client.AchievementClient
|
||||
registrationReward client.RegistrationRewardClient
|
||||
firstRechargeReward client.FirstRechargeRewardClient
|
||||
sevenDayCheckIn client.SevenDayCheckInClient
|
||||
broadcastClient client.BroadcastClient
|
||||
gameClient client.GameClient
|
||||
@ -193,6 +194,11 @@ func (h *Handler) SetRegistrationRewardClient(registrationReward client.Registra
|
||||
h.registrationReward = registrationReward
|
||||
}
|
||||
|
||||
// SetFirstRechargeRewardClient 注入 activity-service 首冲奖励查询 client。
|
||||
func (h *Handler) SetFirstRechargeRewardClient(firstRechargeReward client.FirstRechargeRewardClient) {
|
||||
h.firstRechargeReward = firstRechargeReward
|
||||
}
|
||||
|
||||
// SetSevenDayCheckInClient 注入 activity-service 七日签到 client。
|
||||
func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) {
|
||||
h.sevenDayCheckIn = sevenDayCheckIn
|
||||
|
||||
@ -130,6 +130,7 @@ type TaskHandlers struct {
|
||||
ListTaskTabs http.HandlerFunc
|
||||
ClaimTaskReward http.HandlerFunc
|
||||
GetRegistrationRewardEligibility http.HandlerFunc
|
||||
GetFirstRechargeRewardStatus http.HandlerFunc
|
||||
GetSevenDayCheckInStatus http.HandlerFunc
|
||||
SignSevenDayCheckIn http.HandlerFunc
|
||||
ListUserLeaderboards http.HandlerFunc
|
||||
@ -324,6 +325,7 @@ func (r routes) registerMessageRoutes() {
|
||||
func (r routes) registerTaskRoutes() {
|
||||
h := r.config.Task
|
||||
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility)
|
||||
r.profile("/activities/first-recharge-reward", http.MethodGet, h.GetFirstRechargeRewardStatus)
|
||||
r.profile("/activities/seven-day-checkin", http.MethodGet, h.GetSevenDayCheckInStatus)
|
||||
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
|
||||
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)
|
||||
|
||||
@ -57,14 +57,15 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
UserProfileClient: h.userProfileClient,
|
||||
})
|
||||
activityAPI := activityapi.New(activityapi.Config{
|
||||
TaskClient: h.taskClient,
|
||||
GrowthLevelClient: h.growthLevelClient,
|
||||
AchievementClient: h.achievementClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
WalletClient: h.walletClient,
|
||||
WalletDB: h.leaderboardWalletDB,
|
||||
RegistrationReward: h.registrationReward,
|
||||
SevenDayCheckIn: h.sevenDayCheckIn,
|
||||
TaskClient: h.taskClient,
|
||||
GrowthLevelClient: h.growthLevelClient,
|
||||
AchievementClient: h.achievementClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
WalletClient: h.walletClient,
|
||||
WalletDB: h.leaderboardWalletDB,
|
||||
RegistrationReward: h.registrationReward,
|
||||
FirstRechargeReward: h.firstRechargeReward,
|
||||
SevenDayCheckIn: h.sevenDayCheckIn,
|
||||
})
|
||||
gameAPI := gameapi.New(gameapi.Config{
|
||||
GameClient: h.gameClient,
|
||||
|
||||
@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_records (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
recharge_sequence BIGINT NOT NULL DEFAULT 0 COMMENT '该用户成功充值序号,从 1 开始',
|
||||
seller_user_id BIGINT NOT NULL COMMENT '商户用户 ID',
|
||||
seller_region_id BIGINT NOT NULL COMMENT '商户区域 ID',
|
||||
target_region_id BIGINT NOT NULL COMMENT '目标区域 ID',
|
||||
@ -129,6 +130,28 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_records (
|
||||
KEY idx_wallet_recharge_records_region_time (app_code, target_region_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_user_recharge_stats (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
recharge_count BIGINT NOT NULL DEFAULT 0 COMMENT '成功充值次数',
|
||||
first_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首笔充值交易 ID',
|
||||
first_recharged_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首笔充值时间,UTC epoch ms',
|
||||
total_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '累计充值金币',
|
||||
total_usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT '累计充值美元最小单位金额',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户充值统计表';
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'wallet_recharge_records' AND COLUMN_NAME = 'recharge_sequence') = 0,
|
||||
'ALTER TABLE wallet_recharge_records ADD COLUMN recharge_sequence BIGINT NOT NULL DEFAULT 0 COMMENT ''该用户成功充值序号,从 1 开始'' AFTER user_id',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_recharge_products (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
product_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '商品 ID',
|
||||
|
||||
@ -96,6 +96,7 @@ type CoinSellerTransferReceipt struct {
|
||||
SellerBalanceAfter int64
|
||||
TargetBalanceAfter int64
|
||||
Amount int64
|
||||
RechargeSequence int64
|
||||
RechargeUSDMinor int64
|
||||
RechargeCurrencyCode string
|
||||
RechargePolicyID int64
|
||||
|
||||
@ -740,6 +740,10 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
|
||||
if err != nil {
|
||||
return ledger.CoinSellerTransferReceipt{}, err
|
||||
}
|
||||
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs)
|
||||
if err != nil {
|
||||
return ledger.CoinSellerTransferReceipt{}, err
|
||||
}
|
||||
sellerAfter := seller.AvailableAmount - command.Amount
|
||||
targetAfter := target.AvailableAmount + command.Amount
|
||||
metadata := coinSellerTransferMetadata{
|
||||
@ -754,6 +758,7 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
|
||||
TargetAssetType: ledger.AssetCoin,
|
||||
SellerBalanceAfter: sellerAfter,
|
||||
TargetBalanceAfter: targetAfter,
|
||||
RechargeSequence: rechargeSequence,
|
||||
RechargeUSDMinor: rechargeUSDMinor,
|
||||
RechargeCurrencyCode: policy.CurrencyCode,
|
||||
RechargePolicyID: policy.PolicyID,
|
||||
@ -1062,13 +1067,14 @@ func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transact
|
||||
func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO wallet_recharge_records (
|
||||
app_code, transaction_id, user_id, seller_user_id, seller_region_id, target_region_id,
|
||||
app_code, transaction_id, user_id, recharge_sequence, seller_user_id, seller_region_id, target_region_id,
|
||||
policy_id, policy_version, currency_code, coin_amount, usd_minor_amount,
|
||||
exchange_coin_amount, exchange_usd_minor_amount, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
normalizedEntryAppCode(ctx, metadata.AppCode),
|
||||
transactionID,
|
||||
metadata.TargetUserID,
|
||||
metadata.RechargeSequence,
|
||||
metadata.SellerUserID,
|
||||
metadata.SellerRegionID,
|
||||
metadata.TargetRegionID,
|
||||
@ -1084,6 +1090,48 @@ func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, trans
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) {
|
||||
normalizedApp := normalizedEntryAppCode(ctx, appCode)
|
||||
var count int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT recharge_count
|
||||
FROM wallet_user_recharge_stats
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
FOR UPDATE`,
|
||||
normalizedApp, userID,
|
||||
).Scan(&count)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO wallet_user_recharge_stats (
|
||||
app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms,
|
||||
total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`,
|
||||
normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
next := count + 1
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE wallet_user_recharge_stats
|
||||
SET recharge_count = ?,
|
||||
total_coin_amount = total_coin_amount + ?,
|
||||
total_usd_minor_amount = total_usd_minor_amount + ?,
|
||||
updated_at_ms = ?
|
||||
WHERE app_code = ? AND user_id = ?`,
|
||||
next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
@ -1333,6 +1381,7 @@ type coinSellerTransferMetadata struct {
|
||||
TargetAssetType string `json:"target_asset_type"`
|
||||
SellerBalanceAfter int64 `json:"seller_balance_after"`
|
||||
TargetBalanceAfter int64 `json:"target_balance_after"`
|
||||
RechargeSequence int64 `json:"recharge_sequence"`
|
||||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||
RechargeCurrencyCode string `json:"recharge_currency_code"`
|
||||
RechargePolicyID int64 `json:"recharge_policy_id"`
|
||||
@ -1500,6 +1549,7 @@ func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSe
|
||||
SellerBalanceAfter: metadata.SellerBalanceAfter,
|
||||
TargetBalanceAfter: metadata.TargetBalanceAfter,
|
||||
Amount: metadata.Amount,
|
||||
RechargeSequence: metadata.RechargeSequence,
|
||||
RechargeUSDMinor: metadata.RechargeUSDMinor,
|
||||
RechargeCurrencyCode: metadata.RechargeCurrencyCode,
|
||||
RechargePolicyID: metadata.RechargePolicyID,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user