zeeone 接口

This commit is contained in:
zhx 2026-05-22 11:57:04 +08:00
parent 8dc76890a6
commit 4af7844d1b
38 changed files with 5125 additions and 593 deletions

File diff suppressed because it is too large Load Diff

View File

@ -531,6 +531,122 @@ message ListRegistrationRewardClaimsResponse {
int64 total = 2; 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 // SevenDayCheckInReward
message SevenDayCheckInReward { message SevenDayCheckInReward {
int32 day_index = 1; int32 day_index = 1;
@ -1243,6 +1359,19 @@ service AdminRegistrationRewardService {
rpc ListRegistrationRewardClaims(ListRegistrationRewardClaimsRequest) returns (ListRegistrationRewardClaimsResponse); 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 // SevenDayCheckInService App
service SevenDayCheckInService { service SevenDayCheckInService {
rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse); rpc GetSevenDayCheckInStatus(GetSevenDayCheckInStatusRequest) returns (GetSevenDayCheckInStatusResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc (unknown) // - protoc v4.25.3
// source: proto/activity/v1/activity.proto // source: proto/activity/v1/activity.proto
package activityv1 package activityv1
@ -2319,6 +2319,334 @@ var AdminRegistrationRewardService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto", 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 ( const (
SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus" SevenDayCheckInService_GetSevenDayCheckInStatus_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/GetSevenDayCheckInStatus"
SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn" SevenDayCheckInService_SignSevenDayCheckIn_FullMethodName = "/hyapp.activity.v1.SevenDayCheckInService/SignSevenDayCheckIn"

View File

@ -34,6 +34,7 @@ import (
countryregionmodule "hyapp-admin-server/internal/modules/countryregion" countryregionmodule "hyapp-admin-server/internal/modules/countryregion"
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask" dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
dashboardmodule "hyapp-admin-server/internal/modules/dashboard" dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement" gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
healthmodule "hyapp-admin-server/internal/modules/health" healthmodule "hyapp-admin-server/internal/modules/health"
hostorgmodule "hyapp-admin-server/internal/modules/hostorg" hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
@ -210,35 +211,36 @@ func main() {
objectUploader = uploader objectUploader = uploader
} }
handlers := router.Handlers{ handlers := router.Handlers{
Audit: auditHandler, Audit: auditHandler,
Auth: authmodule.New(store, auth, auditHandler, cfg), Auth: authmodule.New(store, auth, auditHandler, cfg),
AdminUser: adminusermodule.New(store, cfg, auditHandler), AdminUser: adminusermodule.New(store, cfg, auditHandler),
AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), AchievementConfig: achievementconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
AppConfig: appconfigmodule.New(store, auditHandler), AppConfig: appconfigmodule.New(store, auditHandler),
AppRegistry: appregistrymodule.New(userDB), AppRegistry: appregistrymodule.New(userDB),
AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler), AppUser: appusermodule.New(userclient.NewGRPC(userConn), activityclient.NewGRPC(activityConn), sqlDB, userDB, walletDB, cfg, auditHandler),
CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler), CoinLedger: coinledgermodule.New(userDB, walletDB, sqlDB, walletclient.NewGRPC(walletConn), auditHandler),
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler), DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Dashboard: dashboardmodule.New(store), Dashboard: dashboardmodule.New(store),
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler), FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Health: healthmodule.New(store, redisClient, jobStatus), Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler), Health: healthmodule.New(store, redisClient, jobStatus),
Job: jobmodule.New(store, cfg, auditHandler), HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler), Job: jobmodule.New(store, cfg, auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler), LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Menu: menumodule.New(store, auditHandler), LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler), Menu: menumodule.New(store, auditHandler),
RBAC: rbacmodule.New(store, auditHandler), Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), RBAC: rbacmodule.New(store, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler), RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler), RegionBlock: regionblockmodule.New(userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler), Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler), RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
Search: searchmodule.New(store), RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Search: searchmodule.New(store),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB), Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomDB),
} }
engine := router.New(cfg, auth, handlers) engine := router.New(cfg, auth, handlers)

View File

@ -18,6 +18,9 @@ type Client interface {
GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error) GetRegistrationRewardConfig(ctx context.Context, req *activityv1.GetRegistrationRewardConfigRequest) (*activityv1.GetRegistrationRewardConfigResponse, error)
UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error) UpdateRegistrationRewardConfig(ctx context.Context, req *activityv1.UpdateRegistrationRewardConfigRequest) (*activityv1.UpdateRegistrationRewardConfigResponse, error)
ListRegistrationRewardClaims(ctx context.Context, req *activityv1.ListRegistrationRewardClaimsRequest) (*activityv1.ListRegistrationRewardClaimsResponse, 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) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error)
UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error) UpdateSevenDayCheckInConfig(ctx context.Context, req *activityv1.UpdateSevenDayCheckInConfigRequest) (*activityv1.UpdateSevenDayCheckInConfigResponse, error)
ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error) ListSevenDayCheckInClaims(ctx context.Context, req *activityv1.ListSevenDayCheckInClaimsRequest) (*activityv1.ListSevenDayCheckInClaimsResponse, error)
@ -33,24 +36,26 @@ type Client interface {
} }
type GRPCClient struct { type GRPCClient struct {
taskClient activityv1.AdminTaskServiceClient taskClient activityv1.AdminTaskServiceClient
achievementClient activityv1.AdminAchievementServiceClient achievementClient activityv1.AdminAchievementServiceClient
registrationRewardClient activityv1.AdminRegistrationRewardServiceClient registrationRewardClient activityv1.AdminRegistrationRewardServiceClient
sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient firstRechargeRewardClient activityv1.AdminFirstRechargeRewardServiceClient
luckyGiftClient activityv1.AdminLuckyGiftServiceClient sevenDayCheckInClient activityv1.AdminSevenDayCheckInServiceClient
broadcastClient activityv1.BroadcastServiceClient luckyGiftClient activityv1.AdminLuckyGiftServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient broadcastClient activityv1.BroadcastServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
} }
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient { func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{ return &GRPCClient{
taskClient: activityv1.NewAdminTaskServiceClient(conn), taskClient: activityv1.NewAdminTaskServiceClient(conn),
achievementClient: activityv1.NewAdminAchievementServiceClient(conn), achievementClient: activityv1.NewAdminAchievementServiceClient(conn),
registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn), registrationRewardClient: activityv1.NewAdminRegistrationRewardServiceClient(conn),
sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn), firstRechargeRewardClient: activityv1.NewAdminFirstRechargeRewardServiceClient(conn),
luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn), sevenDayCheckInClient: activityv1.NewAdminSevenDayCheckInServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn), luckyGiftClient: activityv1.NewAdminLuckyGiftServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(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) 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) { func (c *GRPCClient) GetSevenDayCheckInConfig(ctx context.Context, req *activityv1.GetSevenDayCheckInConfigRequest) (*activityv1.GetSevenDayCheckInConfigResponse, error) {
return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req) return c.sevenDayCheckInClient.GetSevenDayCheckInConfig(ctx, req)
} }

View 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, ",")
}

View 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)
}

View File

@ -14,6 +14,7 @@ import (
"hyapp-admin-server/internal/modules/countryregion" "hyapp-admin-server/internal/modules/countryregion"
"hyapp-admin-server/internal/modules/dailytask" "hyapp-admin-server/internal/modules/dailytask"
"hyapp-admin-server/internal/modules/dashboard" "hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/firstrechargereward"
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement" gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
"hyapp-admin-server/internal/modules/health" "hyapp-admin-server/internal/modules/health"
"hyapp-admin-server/internal/modules/hostorg" "hyapp-admin-server/internal/modules/hostorg"
@ -39,35 +40,36 @@ import (
) )
type Handlers struct { type Handlers struct {
AdminUser *adminuser.Handler AdminUser *adminuser.Handler
AchievementConfig *achievementconfig.Handler AchievementConfig *achievementconfig.Handler
AppConfig *appconfig.Handler AppConfig *appconfig.Handler
AppRegistry *appregistry.Handler AppRegistry *appregistry.Handler
AppUser *appuser.Handler AppUser *appuser.Handler
Audit *audit.Handler Audit *audit.Handler
Auth *authroutes.Handler Auth *authroutes.Handler
CoinLedger *coinledger.Handler CoinLedger *coinledger.Handler
CountryRegion *countryregion.Handler CountryRegion *countryregion.Handler
DailyTask *dailytask.Handler DailyTask *dailytask.Handler
Dashboard *dashboard.Handler Dashboard *dashboard.Handler
Game *gamemanagement.Handler FirstRechargeReward *firstrechargereward.Handler
Health *health.Handler Game *gamemanagement.Handler
HostOrg *hostorg.Handler Health *health.Handler
Job *job.Handler HostOrg *hostorg.Handler
LevelConfig *levelconfig.Handler Job *job.Handler
LuckyGift *luckygift.Handler LevelConfig *levelconfig.Handler
Menu *menu.Handler LuckyGift *luckygift.Handler
Payment *payment.Handler Menu *menu.Handler
RBAC *rbac.Handler Payment *payment.Handler
RegistrationReward *registrationreward.Handler RBAC *rbac.Handler
RegionBlock *regionblock.Handler RegistrationReward *registrationreward.Handler
Resource *resourcemodule.Handler RegionBlock *regionblock.Handler
RoomAdmin *roomadmin.Handler Resource *resourcemodule.Handler
RoomTreasure *roomtreasure.Handler RoomAdmin *roomadmin.Handler
Search *search.Handler RoomTreasure *roomtreasure.Handler
SevenDayCheckIn *sevendaycheckin.Handler Search *search.Handler
Upload *upload.Handler SevenDayCheckIn *sevendaycheckin.Handler
UserLeaderboard *userleaderboard.Handler Upload *upload.Handler
UserLeaderboard *userleaderboard.Handler
} }
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { 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) appconfig.RegisterRoutes(protected, h.AppConfig)
countryregion.RegisterRoutes(protected, h.CountryRegion) countryregion.RegisterRoutes(protected, h.CountryRegion)
dailytask.RegisterRoutes(protected, h.DailyTask) dailytask.RegisterRoutes(protected, h.DailyTask)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
registrationreward.RegisterRoutes(protected, h.RegistrationReward) registrationreward.RegisterRoutes(protected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock) regionblock.RegisterRoutes(protected, h.RegionBlock)
gamemanagement.RegisterRoutes(protected, h.Game) gamemanagement.RegisterRoutes(protected, h.Game)

View File

@ -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';

View File

@ -13,6 +13,11 @@ mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=ut
user_service_addr: "user-service:13005" user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004" wallet_service_addr: "wallet-service:13004"
mysql_auto_migrate: false 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: tencent_im:
# Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。 # Docker 本地联调全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true enabled: true

View File

@ -13,6 +13,11 @@ mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=tr
user_service_addr: "user-service.internal:13005" user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004" wallet_service_addr: "wallet-service.internal:13004"
mysql_auto_migrate: false 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: tencent_im:
# 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。 # 腾讯云 IM 应用配置;必须和 gateway 的 UserSig、room-service 房间群配置属于同一个 SDKAppID。
enabled: true enabled: true

View File

@ -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" user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004" wallet_service_addr: "127.0.0.1:13004"
mysql_auto_migrate: false 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: tencent_im:
# activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。 # activity-service 只负责全局/区域播报群;必须与 gateway/room-service 使用同一组腾讯 IM 应用配置。
enabled: true enabled: true

View File

@ -367,6 +367,60 @@ CREATE TABLE IF NOT EXISTS registration_reward_daily_counters (
PRIMARY KEY (app_code, reward_day) PRIMARY KEY (app_code, reward_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='注册奖励每日计数表'; ) 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 ( CREATE TABLE IF NOT EXISTS seven_day_checkin_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用',

View File

@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"log/slog" "log/slog"
"net" "net"
@ -26,6 +27,7 @@ import (
achievementservice "hyapp/services/activity-service/internal/service/achievement" achievementservice "hyapp/services/activity-service/internal/service/achievement"
activityservice "hyapp/services/activity-service/internal/service/activity" activityservice "hyapp/services/activity-service/internal/service/activity"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast" broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
firstrechargeservice "hyapp/services/activity-service/internal/service/firstrecharge"
growthservice "hyapp/services/activity-service/internal/service/growth" growthservice "hyapp/services/activity-service/internal/service/growth"
luckygiftservice "hyapp/services/activity-service/internal/service/luckygift" luckygiftservice "hyapp/services/activity-service/internal/service/luckygift"
messageservice "hyapp/services/activity-service/internal/service/message" messageservice "hyapp/services/activity-service/internal/service/message"
@ -43,8 +45,12 @@ type App struct {
health *grpchealth.ServingChecker health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository mysqlRepo *mysqlstorage.Repository
walletOutboxDB *sql.DB
broadcast *broadcastservice.Service broadcast *broadcastservice.Service
firstRechargeReward *firstrechargeservice.Service
broadcastWorkerEnabled bool broadcastWorkerEnabled bool
firstRechargeWorkerCfg config.FirstRechargeRewardWorkerConfig
workerNodeID string
mqConsumers []*rocketmqx.Consumer mqConsumers []*rocketmqx.Consumer
userConn *grpc.ClientConn userConn *grpc.ClientConn
walletConn *grpc.ClientConn walletConn *grpc.ClientConn
@ -139,10 +145,37 @@ func New(cfg config.Config) (*App, error) {
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc) broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer) activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(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) mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
if cfg.RocketMQ.RoomOutbox.Enabled { if cfg.RocketMQ.RoomOutbox.Enabled {
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ)) consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {
if walletOutboxDB != nil {
_ = walletOutboxDB.Close()
}
_ = walletConn.Close() _ = walletConn.Close()
_ = userConn.Close() _ = userConn.Close()
_ = listener.Close() _ = listener.Close()
@ -161,6 +194,9 @@ func New(cfg config.Config) (*App, error) {
_, err = growthSvc.HandleRoomEvent(eventCtx, envelope) _, err = growthSvc.HandleRoomEvent(eventCtx, envelope)
return err return err
}); err != nil { }); err != nil {
if walletOutboxDB != nil {
_ = walletOutboxDB.Close()
}
_ = walletConn.Close() _ = walletConn.Close()
_ = userConn.Close() _ = userConn.Close()
_ = listener.Close() _ = listener.Close()
@ -169,13 +205,23 @@ func New(cfg config.Config) (*App, error) {
} }
mqConsumers = append(mqConsumers, consumer) mqConsumers = append(mqConsumers, consumer)
} }
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{ healthDependencies := []grpchealth.Dependency{{
Name: "mysql", Name: "mysql",
Check: repository.Ping, 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)) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil { if err != nil {
if walletOutboxDB != nil {
_ = walletOutboxDB.Close()
}
_ = walletConn.Close() _ = walletConn.Close()
_ = userConn.Close() _ = userConn.Close()
_ = listener.Close() _ = listener.Close()
@ -190,8 +236,12 @@ func New(cfg config.Config) (*App, error) {
health: health, health: health,
healthHTTP: healthHTTP, healthHTTP: healthHTTP,
mysqlRepo: repository, mysqlRepo: repository,
walletOutboxDB: walletOutboxDB,
broadcast: broadcastSvc, broadcast: broadcastSvc,
firstRechargeReward: firstRechargeRewardSvc,
broadcastWorkerEnabled: cfg.Broadcast.Enabled, broadcastWorkerEnabled: cfg.Broadcast.Enabled,
firstRechargeWorkerCfg: cfg.FirstRechargeRewardWorker,
workerNodeID: cfg.NodeID,
mqConsumers: mqConsumers, mqConsumers: mqConsumers,
userConn: userConn, userConn: userConn,
walletConn: walletConn, walletConn: walletConn,
@ -213,6 +263,15 @@ func (a *App) Run() error {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{}) 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) err := a.server.Serve(a.listener)
if a.workerStop != nil { if a.workerStop != nil {
a.workerStop() a.workerStop()
@ -244,6 +303,9 @@ func (a *App) Close() {
if a.walletConn != nil { if a.walletConn != nil {
_ = a.walletConn.Close() _ = a.walletConn.Close()
} }
if a.walletOutboxDB != nil {
_ = a.walletOutboxDB.Close()
}
if a.mysqlRepo != nil { if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。 // MySQL 连接池最后关闭,保证 drain 中的查询或消费提交能完成。
_ = a.mysqlRepo.Close() _ = a.mysqlRepo.Close()

View File

@ -25,6 +25,8 @@ type Config struct {
UserServiceAddr string `yaml:"user_service_addr"` UserServiceAddr string `yaml:"user_service_addr"`
// WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。 // WalletServiceAddr 是任务奖励入账依赖activity 只发命令,不直接写余额。
WalletServiceAddr string `yaml:"wallet_service_addr"` WalletServiceAddr string `yaml:"wallet_service_addr"`
// FirstRechargeRewardWorker 控制首冲奖励对 wallet_outbox 充值事实的本地消费。
FirstRechargeRewardWorker FirstRechargeRewardWorkerConfig `yaml:"first_recharge_reward_worker"`
// TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。 // TencentIM 是 activity-service 发送全局/区域播报群消息的 REST 配置。
TencentIM TencentIMConfig `yaml:"tencent_im"` TencentIM TencentIMConfig `yaml:"tencent_im"`
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。 // Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
@ -49,6 +51,18 @@ type MessageInboxConfig struct {
UnreadCacheTTL string `yaml:"unread_cache_ttl"` 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 的配置。 // TencentIMConfig 描述 activity-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct { type TencentIMConfig struct {
Enabled bool `yaml:"enabled"` 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", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC",
UserServiceAddr: "127.0.0.1:13005", UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004", 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{ TencentIM: TencentIMConfig{
Enabled: false, Enabled: false,
AdminIdentifier: "administrator", AdminIdentifier: "administrator",
@ -183,6 +203,16 @@ func Load(path string) (Config, error) {
if strings.TrimSpace(cfg.WalletServiceAddr) == "" { if strings.TrimSpace(cfg.WalletServiceAddr) == "" {
cfg.WalletServiceAddr = Default().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) cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" { if cfg.ServiceName == "" {
cfg.ServiceName = "activity-service" cfg.ServiceName = "activity-service"

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -13,6 +13,7 @@ const (
AdapterDemo = "demo" AdapterDemo = "demo"
AdapterYomiV4 = "yomi_v4" AdapterYomiV4 = "yomi_v4"
AdapterLeaderCCV1 = "leadercc_v1" AdapterLeaderCCV1 = "leadercc_v1"
AdapterZeeOneV1 = "zeeone_v1"
OrderStatusWalletApplying = "wallet_applying" OrderStatusWalletApplying = "wallet_applying"
OrderStatusSucceeded = "succeeded" OrderStatusSucceeded = "succeeded"

View File

@ -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/tokengame-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)
}

View File

@ -9,9 +9,7 @@ import (
"strings" "strings"
"time" "time"
jwt "github.com/golang-jwt/jwt/v5"
gamev1 "hyapp.local/api/proto/game/v1" gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
gamedomain "hyapp/services/game-service/internal/domain/game" 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) { func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
// 灵仙回调直接使用 App 登录 access token无需预先进入游戏创建 launch session。 // 灵仙回调直接使用 App 登录 access token无需预先进入游戏创建 launch session。
if strings.Count(strings.TrimSpace(token), ".") == 2 { if strings.Count(strings.TrimSpace(token), ".") == 2 {
return s.leaderccSessionFromAppToken(app, token) return s.appSessionFromAccessToken(app, token)
} }
// 兼容历史联调 token老链路会先 launch再按 game_launch_sessions 校验。 // 兼容历史联调 token老链路会先 launch再按 game_launch_sessions 校验。
session, err := s.validYomiSession(ctx, app, token) session, err := s.validYomiSession(ctx, app, token)
@ -260,42 +258,6 @@ func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAda
normalizedUID == strconv.FormatInt(session.UserID, 10) 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 { func leaderccOpType(value int32) string {
// 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。 // 厂商枚举在适配器内结束,下游钱包只认识 debit/credit。
switch value { switch value {
@ -344,11 +306,6 @@ func leaderccTokenOnlyProbe(token string, parts ...string) bool {
return true 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) { func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
// signatureValid/providerRequestID 会写入 callback log帮助后续排查签名和重复订单问题。 // signatureValid/providerRequestID 会写入 callback log帮助后续排查签名和重复订单问题。
raw, contentType := leaderccJSON(code, message, map[string]any{}) raw, contentType := leaderccJSON(code, message, map[string]any{})

View File

@ -186,7 +186,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
token := strings.TrimSpace(command.AccessToken) token := strings.TrimSpace(command.AccessToken)
if token == "" { 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") return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
} }
token = "gt_" + randomHex(24) token = "gt_" + randomHex(24)
@ -197,6 +197,8 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL()) sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL())
} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { } else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
sessionTTL = maxDuration(sessionTTL, leaderccConfigFromPlatform(game).TokenTTL()) 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() expiresAt := now.Add(sessionTTL).UnixMilli()
session := gamedomain.LaunchSession{ 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) responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash)
case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1): case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1):
responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleLeaderCCOperation(ctx, app, platform, req, operation, requestHash) 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: default:
responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash) 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。 // 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。
return "", nil return "", nil
} }
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
}
if base == "" { if base == "" {
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
return "", xerr.New(xerr.InvalidArgument, "zeeone launch url is required")
}
// demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。 // demo 平台可以没有真实厂商域名,保留本地默认地址用于开发自测。
base = "https://game.example.local/h5" base = "https://game.example.local/h5"
} }
@ -601,6 +614,33 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
parsed.RawQuery = query.Encode() parsed.RawQuery = query.Encode()
return parsed.String(), nil return parsed.String(), nil
} }
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 直接加载指定游戏 H5sessionId 使用 App access tokenmerchant/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 和订单链路。 // demo adapter 保持内部调试参数完整,方便用一个简单 H5 验证 session 和订单链路。
query.Set("session_id", session.SessionID) query.Set("session_id", session.SessionID)
query.Set("session_token", token) query.Set("session_token", token)

View File

@ -7,6 +7,7 @@ import (
"crypto/md5" "crypto/md5"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"strconv" "strconv"
"strings" "strings"
"testing" "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) { func TestLaunchGameAllowsLeaderCCWithoutLaunchURL(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{ 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) { func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}}, 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 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) + `}`)
}

View 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"
}
}

View File

@ -400,6 +400,8 @@ func normalizeAdapterType(adapterType string) string {
return gamedomain.AdapterYomiV4 return gamedomain.AdapterYomiV4
case gamedomain.AdapterLeaderCCV1: case gamedomain.AdapterLeaderCCV1:
return gamedomain.AdapterLeaderCCV1 return gamedomain.AdapterLeaderCCV1
case gamedomain.AdapterZeeOneV1:
return gamedomain.AdapterZeeOneV1
default: default:
return "" return ""
} }

View File

@ -97,6 +97,7 @@ func New(cfg config.Config) (*App, error) {
var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn) var growthLevelClient client.GrowthLevelClient = client.NewGRPCGrowthLevelClient(activityConn)
var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn) var achievementClient client.AchievementClient = client.NewGRPCAchievementClient(activityConn)
var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn) var registrationRewardClient client.RegistrationRewardClient = client.NewGRPCRegistrationRewardClient(activityConn)
var firstRechargeRewardClient client.FirstRechargeRewardClient = client.NewGRPCFirstRechargeRewardClient(activityConn)
var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn) var sevenDayCheckInClient client.SevenDayCheckInClient = client.NewGRPCSevenDayCheckInClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn) var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn) var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
@ -140,6 +141,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetGrowthLevelClient(growthLevelClient) handler.SetGrowthLevelClient(growthLevelClient)
handler.SetAchievementClient(achievementClient) handler.SetAchievementClient(achievementClient)
handler.SetRegistrationRewardClient(registrationRewardClient) handler.SetRegistrationRewardClient(registrationRewardClient)
handler.SetFirstRechargeRewardClient(firstRechargeRewardClient)
handler.SetSevenDayCheckInClient(sevenDayCheckInClient) handler.SetSevenDayCheckInClient(sevenDayCheckInClient)
handler.SetBroadcastClient(broadcastClient) handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient) handler.SetGameClient(gameClient)

View File

@ -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)
}

View File

@ -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(),
}
}

View File

@ -10,37 +10,40 @@ import (
// Handler owns activity-facing HTTP endpoints. // Handler owns activity-facing HTTP endpoints.
// It keeps task and registration reward reads/writes behind activity-service, with gateway only adding auth context. // It keeps task and registration reward reads/writes behind activity-service, with gateway only adding auth context.
type Handler struct { type Handler struct {
taskClient client.TaskClient taskClient client.TaskClient
growthLevelClient client.GrowthLevelClient growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient achievementClient client.AchievementClient
userProfileClient client.UserProfileClient userProfileClient client.UserProfileClient
walletClient client.WalletClient walletClient client.WalletClient
walletDB *sql.DB walletDB *sql.DB
registrationReward client.RegistrationRewardClient registrationReward client.RegistrationRewardClient
sevenDayCheckIn client.SevenDayCheckInClient firstRechargeReward client.FirstRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient
} }
type Config struct { type Config struct {
TaskClient client.TaskClient TaskClient client.TaskClient
GrowthLevelClient client.GrowthLevelClient GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient AchievementClient client.AchievementClient
UserProfileClient client.UserProfileClient UserProfileClient client.UserProfileClient
WalletClient client.WalletClient WalletClient client.WalletClient
WalletDB *sql.DB WalletDB *sql.DB
RegistrationReward client.RegistrationRewardClient RegistrationReward client.RegistrationRewardClient
SevenDayCheckIn client.SevenDayCheckInClient FirstRechargeReward client.FirstRechargeRewardClient
SevenDayCheckIn client.SevenDayCheckInClient
} }
func New(config Config) *Handler { func New(config Config) *Handler {
return &Handler{ return &Handler{
taskClient: config.TaskClient, taskClient: config.TaskClient,
growthLevelClient: config.GrowthLevelClient, growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient, achievementClient: config.AchievementClient,
userProfileClient: config.UserProfileClient, userProfileClient: config.UserProfileClient,
walletClient: config.WalletClient, walletClient: config.WalletClient,
walletDB: config.WalletDB, walletDB: config.WalletDB,
registrationReward: config.RegistrationReward, registrationReward: config.RegistrationReward,
sevenDayCheckIn: config.SevenDayCheckIn, firstRechargeReward: config.FirstRechargeReward,
sevenDayCheckIn: config.SevenDayCheckIn,
} }
} }
@ -49,6 +52,7 @@ func (h *Handler) TaskHandlers() httproutes.TaskHandlers {
ListTaskTabs: h.listTaskTabs, ListTaskTabs: h.listTaskTabs,
ClaimTaskReward: h.claimTaskReward, ClaimTaskReward: h.claimTaskReward,
GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility, GetRegistrationRewardEligibility: h.getRegistrationRewardEligibility,
GetFirstRechargeRewardStatus: h.getFirstRechargeRewardStatus,
GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus, GetSevenDayCheckInStatus: h.getSevenDayCheckInStatus,
SignSevenDayCheckIn: h.signSevenDayCheckIn, SignSevenDayCheckIn: h.signSevenDayCheckIn,
ListUserLeaderboards: h.listUserLeaderboards, ListUserLeaderboards: h.listUserLeaderboards,

View File

@ -39,6 +39,7 @@ type Handler struct {
growthLevelClient client.GrowthLevelClient growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient achievementClient client.AchievementClient
registrationReward client.RegistrationRewardClient registrationReward client.RegistrationRewardClient
firstRechargeReward client.FirstRechargeRewardClient
sevenDayCheckIn client.SevenDayCheckInClient sevenDayCheckIn client.SevenDayCheckInClient
broadcastClient client.BroadcastClient broadcastClient client.BroadcastClient
gameClient client.GameClient gameClient client.GameClient
@ -193,6 +194,11 @@ func (h *Handler) SetRegistrationRewardClient(registrationReward client.Registra
h.registrationReward = registrationReward h.registrationReward = registrationReward
} }
// SetFirstRechargeRewardClient 注入 activity-service 首冲奖励查询 client。
func (h *Handler) SetFirstRechargeRewardClient(firstRechargeReward client.FirstRechargeRewardClient) {
h.firstRechargeReward = firstRechargeReward
}
// SetSevenDayCheckInClient 注入 activity-service 七日签到 client。 // SetSevenDayCheckInClient 注入 activity-service 七日签到 client。
func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) { func (h *Handler) SetSevenDayCheckInClient(sevenDayCheckIn client.SevenDayCheckInClient) {
h.sevenDayCheckIn = sevenDayCheckIn h.sevenDayCheckIn = sevenDayCheckIn

View File

@ -130,6 +130,7 @@ type TaskHandlers struct {
ListTaskTabs http.HandlerFunc ListTaskTabs http.HandlerFunc
ClaimTaskReward http.HandlerFunc ClaimTaskReward http.HandlerFunc
GetRegistrationRewardEligibility http.HandlerFunc GetRegistrationRewardEligibility http.HandlerFunc
GetFirstRechargeRewardStatus http.HandlerFunc
GetSevenDayCheckInStatus http.HandlerFunc GetSevenDayCheckInStatus http.HandlerFunc
SignSevenDayCheckIn http.HandlerFunc SignSevenDayCheckIn http.HandlerFunc
ListUserLeaderboards http.HandlerFunc ListUserLeaderboards http.HandlerFunc
@ -324,6 +325,7 @@ func (r routes) registerMessageRoutes() {
func (r routes) registerTaskRoutes() { func (r routes) registerTaskRoutes() {
h := r.config.Task h := r.config.Task
r.profile("/activities/registration-reward/eligibility", http.MethodGet, h.GetRegistrationRewardEligibility) 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", http.MethodGet, h.GetSevenDayCheckInStatus)
r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn) r.profile("/activities/seven-day-checkin/sign", http.MethodPost, h.SignSevenDayCheckIn)
r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards) r.profile("/activities/user-leaderboards", http.MethodGet, h.ListUserLeaderboards)

View File

@ -57,14 +57,15 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserProfileClient: h.userProfileClient, UserProfileClient: h.userProfileClient,
}) })
activityAPI := activityapi.New(activityapi.Config{ activityAPI := activityapi.New(activityapi.Config{
TaskClient: h.taskClient, TaskClient: h.taskClient,
GrowthLevelClient: h.growthLevelClient, GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient, AchievementClient: h.achievementClient,
UserProfileClient: h.userProfileClient, UserProfileClient: h.userProfileClient,
WalletClient: h.walletClient, WalletClient: h.walletClient,
WalletDB: h.leaderboardWalletDB, WalletDB: h.leaderboardWalletDB,
RegistrationReward: h.registrationReward, RegistrationReward: h.registrationReward,
SevenDayCheckIn: h.sevenDayCheckIn, FirstRechargeReward: h.firstRechargeReward,
SevenDayCheckIn: h.sevenDayCheckIn,
}) })
gameAPI := gameapi.New(gameapi.Config{ gameAPI := gameapi.New(gameapi.Config{
GameClient: h.gameClient, GameClient: h.gameClient,

View File

@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID', transaction_id VARCHAR(96) NOT NULL COMMENT '钱包交易 ID',
user_id BIGINT 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_user_id BIGINT NOT NULL COMMENT '商户用户 ID',
seller_region_id BIGINT NOT NULL COMMENT '商户区域 ID', seller_region_id BIGINT NOT NULL COMMENT '商户区域 ID',
target_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) 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='钱包充值记录表'; ) 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 ( CREATE TABLE IF NOT EXISTS wallet_recharge_products (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
product_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '商品 ID', product_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '商品 ID',

View File

@ -96,6 +96,7 @@ type CoinSellerTransferReceipt struct {
SellerBalanceAfter int64 SellerBalanceAfter int64
TargetBalanceAfter int64 TargetBalanceAfter int64
Amount int64 Amount int64
RechargeSequence int64
RechargeUSDMinor int64 RechargeUSDMinor int64
RechargeCurrencyCode string RechargeCurrencyCode string
RechargePolicyID int64 RechargePolicyID int64

View File

@ -740,6 +740,10 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
if err != nil { if err != nil {
return ledger.CoinSellerTransferReceipt{}, err 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 sellerAfter := seller.AvailableAmount - command.Amount
targetAfter := target.AvailableAmount + command.Amount targetAfter := target.AvailableAmount + command.Amount
metadata := coinSellerTransferMetadata{ metadata := coinSellerTransferMetadata{
@ -754,6 +758,7 @@ func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.
TargetAssetType: ledger.AssetCoin, TargetAssetType: ledger.AssetCoin,
SellerBalanceAfter: sellerAfter, SellerBalanceAfter: sellerAfter,
TargetBalanceAfter: targetAfter, TargetBalanceAfter: targetAfter,
RechargeSequence: rechargeSequence,
RechargeUSDMinor: rechargeUSDMinor, RechargeUSDMinor: rechargeUSDMinor,
RechargeCurrencyCode: policy.CurrencyCode, RechargeCurrencyCode: policy.CurrencyCode,
RechargePolicyID: policy.PolicyID, 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 { func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error {
_, err := tx.ExecContext(ctx, _, err := tx.ExecContext(ctx,
`INSERT INTO wallet_recharge_records ( `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, policy_id, policy_version, currency_code, coin_amount, usd_minor_amount,
exchange_coin_amount, exchange_usd_minor_amount, created_at_ms exchange_coin_amount, exchange_usd_minor_amount, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
normalizedEntryAppCode(ctx, metadata.AppCode), normalizedEntryAppCode(ctx, metadata.AppCode),
transactionID, transactionID,
metadata.TargetUserID, metadata.TargetUserID,
metadata.RechargeSequence,
metadata.SellerUserID, metadata.SellerUserID,
metadata.SellerRegionID, metadata.SellerRegionID,
metadata.TargetRegionID, metadata.TargetRegionID,
@ -1084,6 +1090,48 @@ func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, trans
return err 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 { func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
metadataJSON, err := json.Marshal(metadata) metadataJSON, err := json.Marshal(metadata)
if err != nil { if err != nil {
@ -1333,6 +1381,7 @@ type coinSellerTransferMetadata struct {
TargetAssetType string `json:"target_asset_type"` TargetAssetType string `json:"target_asset_type"`
SellerBalanceAfter int64 `json:"seller_balance_after"` SellerBalanceAfter int64 `json:"seller_balance_after"`
TargetBalanceAfter int64 `json:"target_balance_after"` TargetBalanceAfter int64 `json:"target_balance_after"`
RechargeSequence int64 `json:"recharge_sequence"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"` RechargeUSDMinor int64 `json:"recharge_usd_minor"`
RechargeCurrencyCode string `json:"recharge_currency_code"` RechargeCurrencyCode string `json:"recharge_currency_code"`
RechargePolicyID int64 `json:"recharge_policy_id"` RechargePolicyID int64 `json:"recharge_policy_id"`
@ -1500,6 +1549,7 @@ func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSe
SellerBalanceAfter: metadata.SellerBalanceAfter, SellerBalanceAfter: metadata.SellerBalanceAfter,
TargetBalanceAfter: metadata.TargetBalanceAfter, TargetBalanceAfter: metadata.TargetBalanceAfter,
Amount: metadata.Amount, Amount: metadata.Amount,
RechargeSequence: metadata.RechargeSequence,
RechargeUSDMinor: metadata.RechargeUSDMinor, RechargeUSDMinor: metadata.RechargeUSDMinor,
RechargeCurrencyCode: metadata.RechargeCurrencyCode, RechargeCurrencyCode: metadata.RechargeCurrencyCode,
RechargePolicyID: metadata.RechargePolicyID, RechargePolicyID: metadata.RechargePolicyID,