cp 火箭问题修复
This commit is contained in:
parent
d4eef547a8
commit
40157ef64b
File diff suppressed because it is too large
Load Diff
@ -413,6 +413,9 @@ message CPRelationship {
|
||||
int64 needed_for_next_level = 13;
|
||||
int32 level_progress_percent = 14;
|
||||
bool max_level = 15;
|
||||
// breakup_cost_coins 是当前关系类型后台配置的解除费用;active 关系返回配置值,ended 关系返回实际支付值。
|
||||
int64 breakup_cost_coins = 16;
|
||||
int64 ended_at_ms = 17;
|
||||
}
|
||||
|
||||
message ListCPApplicationsRequest {
|
||||
@ -467,6 +470,52 @@ message ListCPRelationshipsResponse {
|
||||
reserved "max_count_by_relation_type";
|
||||
}
|
||||
|
||||
message PrepareBreakCPRelationshipRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string relationship_id = 3;
|
||||
string command_id = 4;
|
||||
}
|
||||
|
||||
message PrepareBreakCPRelationshipResponse {
|
||||
CPRelationship relationship = 1;
|
||||
string command_id = 2;
|
||||
string status = 3;
|
||||
int64 breakup_cost_coins = 4;
|
||||
string wallet_transaction_id = 5;
|
||||
int64 coin_balance_after = 6;
|
||||
}
|
||||
|
||||
message ConfirmBreakCPRelationshipRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string relationship_id = 3;
|
||||
string command_id = 4;
|
||||
string wallet_transaction_id = 5;
|
||||
int64 paid_coin_amount = 6;
|
||||
int64 coin_balance_after = 7;
|
||||
}
|
||||
|
||||
message ConfirmBreakCPRelationshipResponse {
|
||||
CPRelationship relationship = 1;
|
||||
int64 breakup_cost_coins = 2;
|
||||
string wallet_transaction_id = 3;
|
||||
int64 coin_balance_after = 4;
|
||||
}
|
||||
|
||||
message CancelBreakCPRelationshipRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string relationship_id = 3;
|
||||
string command_id = 4;
|
||||
string reason = 5;
|
||||
}
|
||||
|
||||
message CancelBreakCPRelationshipResponse {
|
||||
string command_id = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message RoomGiftCPEvent {
|
||||
RequestMeta meta = 1;
|
||||
string event_id = 2;
|
||||
@ -958,6 +1007,9 @@ service UserCPService {
|
||||
rpc AcceptCPApplication(AcceptCPApplicationRequest) returns (AcceptCPApplicationResponse);
|
||||
rpc RejectCPApplication(RejectCPApplicationRequest) returns (RejectCPApplicationResponse);
|
||||
rpc ListCPRelationships(ListCPRelationshipsRequest) returns (ListCPRelationshipsResponse);
|
||||
rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse);
|
||||
rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse);
|
||||
rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse);
|
||||
}
|
||||
|
||||
// UserCPInternalService 给 owner outbox 消费 worker 调用,不暴露给 App 或后台。
|
||||
|
||||
@ -1105,10 +1105,13 @@ var UserSocialService_ServiceDesc = grpc.ServiceDesc{
|
||||
}
|
||||
|
||||
const (
|
||||
UserCPService_ListCPApplications_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPApplications"
|
||||
UserCPService_AcceptCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/AcceptCPApplication"
|
||||
UserCPService_RejectCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/RejectCPApplication"
|
||||
UserCPService_ListCPRelationships_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPRelationships"
|
||||
UserCPService_ListCPApplications_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPApplications"
|
||||
UserCPService_AcceptCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/AcceptCPApplication"
|
||||
UserCPService_RejectCPApplication_FullMethodName = "/hyapp.user.v1.UserCPService/RejectCPApplication"
|
||||
UserCPService_ListCPRelationships_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPRelationships"
|
||||
UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship"
|
||||
UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship"
|
||||
UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship"
|
||||
)
|
||||
|
||||
// UserCPServiceClient is the client API for UserCPService service.
|
||||
@ -1121,6 +1124,9 @@ type UserCPServiceClient interface {
|
||||
AcceptCPApplication(ctx context.Context, in *AcceptCPApplicationRequest, opts ...grpc.CallOption) (*AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(ctx context.Context, in *RejectCPApplicationRequest, opts ...grpc.CallOption) (*RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(ctx context.Context, in *ListCPRelationshipsRequest, opts ...grpc.CallOption) (*ListCPRelationshipsResponse, error)
|
||||
PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error)
|
||||
}
|
||||
|
||||
type userCPServiceClient struct {
|
||||
@ -1171,6 +1177,36 @@ func (c *userCPServiceClient) ListCPRelationships(ctx context.Context, in *ListC
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCPServiceClient) PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PrepareBreakCPRelationshipResponse)
|
||||
err := c.cc.Invoke(ctx, UserCPService_PrepareBreakCPRelationship_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCPServiceClient) ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConfirmBreakCPRelationshipResponse)
|
||||
err := c.cc.Invoke(ctx, UserCPService_ConfirmBreakCPRelationship_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userCPServiceClient) CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CancelBreakCPRelationshipResponse)
|
||||
err := c.cc.Invoke(ctx, UserCPService_CancelBreakCPRelationship_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserCPServiceServer is the server API for UserCPService service.
|
||||
// All implementations must embed UnimplementedUserCPServiceServer
|
||||
// for forward compatibility.
|
||||
@ -1181,6 +1217,9 @@ type UserCPServiceServer interface {
|
||||
AcceptCPApplication(context.Context, *AcceptCPApplicationRequest) (*AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(context.Context, *RejectCPApplicationRequest) (*RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error)
|
||||
PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error)
|
||||
mustEmbedUnimplementedUserCPServiceServer()
|
||||
}
|
||||
|
||||
@ -1203,6 +1242,15 @@ func (UnimplementedUserCPServiceServer) RejectCPApplication(context.Context, *Re
|
||||
func (UnimplementedUserCPServiceServer) ListCPRelationships(context.Context, *ListCPRelationshipsRequest) (*ListCPRelationshipsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListCPRelationships not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PrepareBreakCPRelationship not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ConfirmBreakCPRelationship not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CancelBreakCPRelationship not implemented")
|
||||
}
|
||||
func (UnimplementedUserCPServiceServer) mustEmbedUnimplementedUserCPServiceServer() {}
|
||||
func (UnimplementedUserCPServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -1296,6 +1344,60 @@ func _UserCPService_ListCPRelationships_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCPService_PrepareBreakCPRelationship_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PrepareBreakCPRelationshipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCPServiceServer).PrepareBreakCPRelationship(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCPService_PrepareBreakCPRelationship_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCPServiceServer).PrepareBreakCPRelationship(ctx, req.(*PrepareBreakCPRelationshipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCPService_ConfirmBreakCPRelationship_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConfirmBreakCPRelationshipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCPServiceServer).ConfirmBreakCPRelationship(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCPService_ConfirmBreakCPRelationship_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCPServiceServer).ConfirmBreakCPRelationship(ctx, req.(*ConfirmBreakCPRelationshipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserCPService_CancelBreakCPRelationship_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CancelBreakCPRelationshipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserCPServiceServer).CancelBreakCPRelationship(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserCPService_CancelBreakCPRelationship_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserCPServiceServer).CancelBreakCPRelationship(ctx, req.(*CancelBreakCPRelationshipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// UserCPService_ServiceDesc is the grpc.ServiceDesc for UserCPService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -1319,6 +1421,18 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListCPRelationships",
|
||||
Handler: _UserCPService_ListCPRelationships_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PrepareBreakCPRelationship",
|
||||
Handler: _UserCPService_PrepareBreakCPRelationship_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConfirmBreakCPRelationship",
|
||||
Handler: _UserCPService_ConfirmBreakCPRelationship_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CancelBreakCPRelationship",
|
||||
Handler: _UserCPService_CancelBreakCPRelationship_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/user/v1/user.proto",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1264,6 +1264,22 @@ message PurchaseVipResponse {
|
||||
repeated VipRewardItem reward_items = 6;
|
||||
}
|
||||
|
||||
message DebitCPBreakupFeeRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
string relationship_id = 4;
|
||||
string relation_type = 5;
|
||||
int64 amount = 6;
|
||||
}
|
||||
|
||||
message DebitCPBreakupFeeResponse {
|
||||
string transaction_id = 1;
|
||||
int64 coin_spent = 2;
|
||||
int64 coin_balance_after = 3;
|
||||
AssetBalance balance = 4;
|
||||
}
|
||||
|
||||
message GrantVipRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
@ -1657,6 +1673,7 @@ service WalletService {
|
||||
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
rpc DebitCPBreakupFee(DebitCPBreakupFeeRequest) returns (DebitCPBreakupFeeResponse);
|
||||
rpc GrantVip(GrantVipRequest) returns (GrantVipResponse);
|
||||
rpc ListAdminVipLevels(ListAdminVipLevelsRequest) returns (ListAdminVipLevelsResponse);
|
||||
rpc UpdateAdminVipLevels(UpdateAdminVipLevelsRequest) returns (UpdateAdminVipLevelsResponse);
|
||||
|
||||
@ -254,6 +254,7 @@ const (
|
||||
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
|
||||
WalletService_GetMyVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GetMyVip"
|
||||
WalletService_PurchaseVip_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseVip"
|
||||
WalletService_DebitCPBreakupFee_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitCPBreakupFee"
|
||||
WalletService_GrantVip_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantVip"
|
||||
WalletService_ListAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminVipLevels"
|
||||
WalletService_UpdateAdminVipLevels_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateAdminVipLevels"
|
||||
@ -330,6 +331,7 @@ type WalletServiceClient interface {
|
||||
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
|
||||
GetMyVip(ctx context.Context, in *GetMyVipRequest, opts ...grpc.CallOption) (*GetMyVipResponse, error)
|
||||
PurchaseVip(ctx context.Context, in *PurchaseVipRequest, opts ...grpc.CallOption) (*PurchaseVipResponse, error)
|
||||
DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error)
|
||||
GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error)
|
||||
ListAdminVipLevels(ctx context.Context, in *ListAdminVipLevelsRequest, opts ...grpc.CallOption) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, in *UpdateAdminVipLevelsRequest, opts ...grpc.CallOption) (*UpdateAdminVipLevelsResponse, error)
|
||||
@ -885,6 +887,16 @@ func (c *walletServiceClient) PurchaseVip(ctx context.Context, in *PurchaseVipRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) DebitCPBreakupFee(ctx context.Context, in *DebitCPBreakupFeeRequest, opts ...grpc.CallOption) (*DebitCPBreakupFeeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(DebitCPBreakupFeeResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_DebitCPBreakupFee_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GrantVip(ctx context.Context, in *GrantVipRequest, opts ...grpc.CallOption) (*GrantVipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GrantVipResponse)
|
||||
@ -1094,6 +1106,7 @@ type WalletServiceServer interface {
|
||||
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
|
||||
GetMyVip(context.Context, *GetMyVipRequest) (*GetMyVipResponse, error)
|
||||
PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error)
|
||||
DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error)
|
||||
GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error)
|
||||
ListAdminVipLevels(context.Context, *ListAdminVipLevelsRequest) (*ListAdminVipLevelsResponse, error)
|
||||
UpdateAdminVipLevels(context.Context, *UpdateAdminVipLevelsRequest) (*UpdateAdminVipLevelsResponse, error)
|
||||
@ -1278,6 +1291,9 @@ func (UnimplementedWalletServiceServer) GetMyVip(context.Context, *GetMyVipReque
|
||||
func (UnimplementedWalletServiceServer) PurchaseVip(context.Context, *PurchaseVipRequest) (*PurchaseVipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PurchaseVip not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) DebitCPBreakupFee(context.Context, *DebitCPBreakupFeeRequest) (*DebitCPBreakupFeeResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DebitCPBreakupFee not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GrantVip(context.Context, *GrantVipRequest) (*GrantVipResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GrantVip not implemented")
|
||||
}
|
||||
@ -2298,6 +2314,24 @@ func _WalletService_PurchaseVip_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_DebitCPBreakupFee_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DebitCPBreakupFeeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).DebitCPBreakupFee(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_DebitCPBreakupFee_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).DebitCPBreakupFee(ctx, req.(*DebitCPBreakupFeeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GrantVip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GrantVipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2787,6 +2821,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "PurchaseVip",
|
||||
Handler: _WalletService_PurchaseVip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DebitCPBreakupFee",
|
||||
Handler: _WalletService_DebitCPBreakupFee_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GrantVip",
|
||||
Handler: _WalletService_GrantVip_Handler,
|
||||
|
||||
@ -150,7 +150,19 @@ sequenceDiagram
|
||||
- App 关系查询返回当前等级阈值、下一等级阈值、距离下一级和进度百分比。
|
||||
- 资料卡弹窗只能使用 `/api/v1/cp/relationships` 返回的阈值字段展示进度;客户端不能读取后台配置或硬编码等级表。
|
||||
|
||||
## 8. App HTTP接口
|
||||
## 8. 解除关系
|
||||
|
||||
用户可以主动解除自己的 active 关系。解除费用由后台 CP 配置按关系类型维护,单位是金币。
|
||||
|
||||
规则:
|
||||
|
||||
- App 先通过 `/api/v1/cp/relationships` 读取 `breakup_cost_coins` 展示费用。
|
||||
- 解除接口必须传 `command_id`,同一次解除重试复用同一个 `command_id`。
|
||||
- gateway 先在 user-service 创建解除占位,再调用 wallet-service 扣金币,最后确认关系为 `ended`。
|
||||
- 如果 wallet-service 扣费失败,user-service 会取消解除占位,关系仍保持 active。
|
||||
- 解除成功后不会发送 C2C、房间 IM 或区域 IM;客户端使用接口返回和关系列表刷新 UI。
|
||||
|
||||
## 9. App HTTP接口
|
||||
|
||||
所有接口走 `gateway-service`,响应使用 `{code,message,request_id,data}`。
|
||||
|
||||
@ -160,6 +172,7 @@ sequenceDiagram
|
||||
| `POST` | `/api/v1/cp/applications/{application_id}/accept` | 同意申请 |
|
||||
| `POST` | `/api/v1/cp/applications/{application_id}/reject` | 拒绝申请 |
|
||||
| `GET` | `/api/v1/cp/relationships` | 查询我的 active 关系 |
|
||||
| `POST` | `/api/v1/cp/relationships/{relationship_id}/break` | 解除关系并扣金币 |
|
||||
|
||||
`/api/v1/cp/relationships` 的 relationship item 包含:
|
||||
|
||||
@ -177,7 +190,9 @@ sequenceDiagram
|
||||
| `needed_for_next_level` | 距离下一级还需要多少亲密值;满级时为 0 |
|
||||
| `level_progress_percent` | 当前等级内进度百分比,0 到 100 |
|
||||
| `max_level` | 是否已经没有下一等级 |
|
||||
| `breakup_cost_coins` | 当前关系解除需要支付的金币数 |
|
||||
| `formed_at_ms` | 关系形成时间 |
|
||||
| `ended_at_ms` | 解除时间;active 关系为 0 |
|
||||
| `updated_at_ms` | 最近更新时间 |
|
||||
|
||||
这些字段由 user-service 从 `user_cp_level_rules` 计算,gateway 只做透传。客户端资料卡不能根据本地表推算这些值。
|
||||
@ -205,17 +220,33 @@ sequenceDiagram
|
||||
| --- | --- | --- | --- |
|
||||
| `reason` | string | 否 | 拒绝原因,只进入 IM/outbox payload |
|
||||
|
||||
## 9. 数据表
|
||||
解除请求体:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `command_id` | string | 是 | 解除关系幂等键,同一次重试必须复用 |
|
||||
|
||||
解除返回值:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `relationship` | 已解除的关系,`status=ended` |
|
||||
| `breakup_cost_coins` | 本次实际扣除金币数 |
|
||||
| `wallet_transaction_id` | 钱包扣费交易 ID;费用为 0 时为空 |
|
||||
| `coin_balance_after` | 扣费后金币余额;费用为 0 时为 0 |
|
||||
|
||||
## 10. 数据表
|
||||
|
||||
表放在 `user-service` 库,所有表带 `app_code`,时间使用 UTC epoch milliseconds。
|
||||
|
||||
| 表 | 说明 |
|
||||
| --- | --- |
|
||||
| `user_cp_relation_configs` | CP/兄弟/姐妹数量与申请过期配置;CP 数量服务端固定为 1,兄弟/姐妹读取后台配置 |
|
||||
| `user_cp_relation_configs` | CP/兄弟/姐妹数量、申请过期和解除费用配置;CP 数量服务端固定为 1,兄弟/姐妹读取后台配置 |
|
||||
| `user_cp_level_rules` | 等级阈值和奖励资源组 |
|
||||
| `user_cp_gift_event_consumption` | `RoomGiftSent` 消费幂等 |
|
||||
| `user_cp_applications` | CP 关系申请 |
|
||||
| `user_cp_relationships` | 双人关系事实 |
|
||||
| `user_cp_relationship_breakups` | 解除关系 pending/confirmed/canceled 占位和幂等事实 |
|
||||
| `user_cp_level_reward_grants` | 等级奖励发放幂等 |
|
||||
| `user_outbox` | CP 事件事实 |
|
||||
|
||||
@ -225,8 +256,9 @@ sequenceDiagram
|
||||
- CP active 关系按单用户唯一:任一用户已有 CP 关系就不能再接受新的 CP 关系。
|
||||
- 兄弟/姐妹 active 关系按后台配置做单用户数量上限校验。
|
||||
- pending 申请按用户对和类型唯一:`(app_code, requester_user_id, target_user_id, relation_type, status='pending')`。
|
||||
- 解除关系按 `command_id` 幂等;同一关系同一时间只能有一条 pending 解除占位。
|
||||
|
||||
## 10. 相关IM
|
||||
## 11. 相关IM
|
||||
|
||||
| IM | 通道 | 发送时机 |
|
||||
| --- | --- | --- |
|
||||
@ -238,10 +270,11 @@ sequenceDiagram
|
||||
| `cp_application_rejected` | C2C | B 拒绝后,B 发给 A |
|
||||
| `cp_application_rejected` | 房间群 | B 拒绝后,发到来源房间,payload 带 `recipient_user_id=A` |
|
||||
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
|
||||
| 无 | 无 | 解除关系成功不发 IM,App 根据解除接口返回刷新 |
|
||||
|
||||
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝为 B;同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_<region_id>`,本地测试可通过 `group_id_prefix` 加前缀。
|
||||
|
||||
## 11. 错误码
|
||||
## 12. 错误码
|
||||
|
||||
| 场景 | code | 说明 |
|
||||
| --- | --- | --- |
|
||||
@ -249,9 +282,11 @@ C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝
|
||||
| 申请已过期 | `CONFLICT` | 不能再同意;内部 gRPC 语义是 FailedPrecondition |
|
||||
| 已有关系 | `CONFLICT` | A/B 任一方已经有 active 关系 |
|
||||
| 无权限处理 | `PERMISSION_DENIED` | 不是申请目标用户 |
|
||||
| 余额不足 | `INSUFFICIENT_BALANCE` | 解除关系扣金币失败 |
|
||||
| 关系不可解除 | `CONFLICT` | 关系不是 active,或已有 pending 解除 |
|
||||
| 幂等冲突 | `CONFLICT` | command_id 或事件消费 payload 不一致 |
|
||||
|
||||
## 12. 验证项
|
||||
## 13. 验证项
|
||||
|
||||
- CP 礼物发送成功后,`RoomGiftSent` 带 `gift_type_code=cp`、`cp_relation_type`、礼物 icon 快照。
|
||||
- A 给 B 连发 `cp`、`brother`、`sister` 三种礼物,会生成三条 pending 申请。
|
||||
@ -259,4 +294,5 @@ C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝
|
||||
- A/B 建立关系后,A 或 B 都不能再和第三人建立 active 关系。
|
||||
- pending 申请超过默认 24 小时后不能接受。
|
||||
- active 关系双方互送任意礼物,亲密值按 `heat_value` 增加。
|
||||
- active 关系解除会扣除后台配置的金币费用,扣费失败不改变关系状态。
|
||||
- 同意、拒绝、关系成立的 IM 都写入 `notice_delivery_events`,失败可重试。
|
||||
|
||||
@ -279,7 +279,9 @@ X-App-Code: lalu
|
||||
"needed_for_next_level": 1112,
|
||||
"level_progress_percent": 44,
|
||||
"max_level": false,
|
||||
"breakup_cost_coins": 88,
|
||||
"formed_at_ms": 1779259100000,
|
||||
"ended_at_ms": 0,
|
||||
"updated_at_ms": 1779259200000
|
||||
}
|
||||
],
|
||||
@ -295,12 +297,60 @@ X-App-Code: lalu
|
||||
- 资料卡弹窗只使用后端返回的 `intimacy_value`、`level`、`current_level_threshold`、`next_level_threshold`、`needed_for_next_level`、`level_progress_percent` 和 `max_level`。
|
||||
- `max_level=true` 或 `next_level_threshold=0` 时表示没有下一等级,隐藏“距离下一级”和进度条。
|
||||
- Flutter 不要用本地常量、礼物价格或后台配置接口自行推算进度条和“距离下一级”。
|
||||
- `breakup_cost_coins` 是解除当前关系需要支付的金币数,弹窗和二次确认直接使用该值。
|
||||
|
||||
## 7. 相关IM
|
||||
## 7. 解除关系
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/cp/relationships/{relationship_id}/break
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"command_id": "break_cp_rel_10001_10002_1779259300000"
|
||||
}
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `relationship_id` | string | 是 | URL path,关系 ID |
|
||||
| `command_id` | string | 是 | 解除关系幂等键,同一次重试必须复用 |
|
||||
|
||||
返回值:
|
||||
|
||||
```json
|
||||
{
|
||||
"relationship": {
|
||||
"relationship_id": "cp_rel_10001_10002",
|
||||
"relation_type": "cp",
|
||||
"status": "ended",
|
||||
"breakup_cost_coins": 88,
|
||||
"formed_at_ms": 1779259100000,
|
||||
"ended_at_ms": 1779259300000,
|
||||
"updated_at_ms": 1779259300000
|
||||
},
|
||||
"breakup_cost_coins": 88,
|
||||
"wallet_transaction_id": "wallet_tx_break_1",
|
||||
"coin_balance_after": 912
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 解除前先展示 `/api/v1/cp/relationships` 返回的 `breakup_cost_coins`。
|
||||
- 金币不足时接口返回 `INSUFFICIENT_BALANCE`,关系仍保持 active。
|
||||
- 解除成功不发 C2C、房间 IM 或区域 IM;Flutter 使用接口返回刷新资料卡和关系列表。
|
||||
|
||||
## 8. 相关IM
|
||||
|
||||
所有 IM 都是 Tencent `TIMCustomElem`,`MsgContent.Desc` 等于下表事件名,`MsgContent.Data` 是 JSON 字符串。客户端按 `event_id` 去重。
|
||||
|
||||
### 7.1 收到关系申请
|
||||
### 8.1 收到关系申请
|
||||
|
||||
房间 IM 和 C2C IM 都会收到:
|
||||
|
||||
@ -354,7 +404,7 @@ Data:
|
||||
- C2C IM 在私聊里显示同意/拒绝按钮。
|
||||
- `expires_at_ms <= server_time_ms` 时按钮置灰并刷新申请列表。
|
||||
|
||||
### 7.2 申请被同意
|
||||
### 8.2 申请被同意
|
||||
|
||||
C2C 会收到双方互发的两条消息,房间 IM 仍然只给申请发起人 A 展示:
|
||||
|
||||
@ -384,7 +434,7 @@ C2C 差异:
|
||||
- B 操作成功后也刷新关系列表。
|
||||
- 同一对用户其他 pending 申请需要置灰。
|
||||
|
||||
### 7.3 申请被拒绝
|
||||
### 8.3 申请被拒绝
|
||||
|
||||
C2C 和房间 IM 都会收到:
|
||||
|
||||
@ -402,7 +452,7 @@ Data 字段和申请消息一致,差异是:
|
||||
| `decided_at_ms` | 处理时间 |
|
||||
| `recipient_user_id` | 房间 IM 中为 A 的 user_id |
|
||||
|
||||
### 7.4 区域关系成立播报
|
||||
### 8.4 区域关系成立播报
|
||||
|
||||
用户组成任意关系后,会发送区域 IM:
|
||||
|
||||
|
||||
@ -34,6 +34,18 @@
|
||||
| `levels[].in_room_rewards` | 在房奖励候选 |
|
||||
| `levels[].top1_rewards` | 贡献第一奖励候选 |
|
||||
| `levels[].igniter_rewards` | 点火人奖励候选 |
|
||||
| `levels[].*_rewards[].resource_group_id` | 奖励资源组 ID |
|
||||
| `levels[].*_rewards[].resource_group` | 奖励资源组详情 |
|
||||
| `levels[].*_rewards[].resource_group.items` | 资源组明细 |
|
||||
| `levels[].*_rewards[].resource_group.items[].item_type` | `wallet_asset` 或 `resource` |
|
||||
| `levels[].*_rewards[].resource_group.items[].wallet_asset_type` | 金币等奖励资产类型,例如 `COIN` |
|
||||
| `levels[].*_rewards[].resource_group.items[].wallet_asset_amount` | 金币等奖励数量 |
|
||||
| `levels[].*_rewards[].resource_group.items[].resource` | 聊天气泡、麦位特效等资源素材 |
|
||||
| `levels[].*_rewards[].resource_group.items[].resource.resource_type` | 资源类型,例如 `chat_bubble`、`mic_effect` |
|
||||
| `levels[].*_rewards[].resource_group.items[].resource.asset_url` | 资源主素材 |
|
||||
| `levels[].*_rewards[].resource_group.items[].resource.preview_url` | 资源预览图 |
|
||||
| `levels[].*_rewards[].resource_group.items[].resource.animation_url` | 资源动效 |
|
||||
| `levels[].*_rewards[].resource_group.items[].duration_ms` | 资源有效期毫秒,`0` 表示永久 |
|
||||
| `state.current_level` | 当前正在累计的等级 |
|
||||
| `state.current_fuel` | 当前等级已累计燃料 |
|
||||
| `state.fuel_threshold` | 当前等级阈值 |
|
||||
@ -48,8 +60,12 @@
|
||||
| `state.pending_launches[].igniter_user_id` | 点火人 |
|
||||
| `state.pending_launches[].cover_url` | 小封面图 |
|
||||
| `state.last_rewards` | 最近一次发射奖励结果 |
|
||||
| `state.last_rewards[].resource_group_id` | 本次中奖资源组 ID |
|
||||
| `state.last_rewards[].resource_group` | 本次中奖资源组详情,字段同上 |
|
||||
| `server_time_ms` | 服务端 UTC 毫秒 |
|
||||
|
||||
`levels[].*_rewards` 表示 `in_room_rewards`、`top1_rewards`、`igniter_rewards` 三个奖励池。客户端展示金币数量、奖励天数、聊天气泡、麦位特效时,直接读 `resource_group.items`。
|
||||
|
||||
## 送礼
|
||||
|
||||
地址:`POST /api/v1/rooms/gift/send`
|
||||
@ -258,6 +274,8 @@
|
||||
| `rewards[].grant_id` | 发奖回执 |
|
||||
| `rewards[].status` | 发奖状态 |
|
||||
|
||||
奖励 IM 用于通知本次哪些人中奖。需要渲染金币数量、奖励天数、聊天气泡或麦位特效素材时,收到 IM 后调用 `GET /api/v1/rooms/{room_id}/rocket`,读取 `state.last_rewards[].resource_group`。
|
||||
|
||||
## 客户端处理
|
||||
|
||||
- 进房先调 `GET /api/v1/rooms/{room_id}/rocket`。
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
## 服务边界
|
||||
|
||||
- `room-service` 是火箭进度、点火、待发射队列和奖励结算 owner。
|
||||
- `gateway-service` 只提供 App HTTP 入口和鉴权后的协议转换。
|
||||
- `gateway-service` 只提供 App HTTP 入口和鉴权后的协议转换;`GET /api/v1/rooms/{room_id}/rocket` 会按奖励 `resource_group_id` 查询 wallet-service 并嵌入 `resource_group.items`。
|
||||
- `server/admin` 只读写 `room-service` 内部配置接口,不直连 room 数据库。
|
||||
- `wallet-service` 负责资源组发放。
|
||||
- `activity-service` 负责区域/全局飘屏和带用户资料的奖励房间 IM。
|
||||
@ -92,6 +92,7 @@
|
||||
| `RoomRocketRewardGranted` | `room_rocket_reward_granted` 房间奖励 IM | activity-service |
|
||||
|
||||
奖励 IM 由 activity-service 查询用户公开资料后组装,包含头像、昵称、短 ID 和奖励信息。room-service 不反查 user-service。
|
||||
奖励资源组内容不写入 Room Cell;客户端需要金币数量、有效期、聊天气泡或麦位特效素材时,通过 rocket GET 接口读取展开后的 `resource_group`。
|
||||
|
||||
## 高频处理
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ type relationDTO struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
MaxCountPerUser int32 `json:"maxCountPerUser"`
|
||||
ApplicationExpireHours int32 `json:"applicationExpireHours"`
|
||||
BreakupCostCoins int64 `json:"breakupCostCoins"`
|
||||
Status string `json:"status"`
|
||||
Levels []levelDTO `json:"levels"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
@ -109,15 +110,16 @@ func (s *Service) UpdateConfig(ctx context.Context, appCode string, req updateRe
|
||||
for _, relation := range relations {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_cp_relation_configs (
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours,
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
|
||||
status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
max_count_per_user = VALUES(max_count_per_user),
|
||||
application_expire_hours = VALUES(application_expire_hours),
|
||||
breakup_cost_coins = VALUES(breakup_cost_coins),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode, relation.RelationType, relation.MaxCountPerUser, relation.ApplicationExpireHours,
|
||||
appCode, relation.RelationType, relation.MaxCountPerUser, relation.ApplicationExpireHours, relation.BreakupCostCoins,
|
||||
relation.Status, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return configDTO{}, err
|
||||
@ -149,7 +151,7 @@ func (s *Service) UpdateConfig(ctx context.Context, appCode string, req updateRe
|
||||
|
||||
func (s *Service) listRelations(ctx context.Context, appCode string) ([]relationDTO, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT relation_type, max_count_per_user, application_expire_hours, status, updated_at_ms
|
||||
SELECT relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins, status, updated_at_ms
|
||||
FROM user_cp_relation_configs
|
||||
WHERE app_code = ?`, appCode)
|
||||
if err != nil {
|
||||
@ -159,7 +161,7 @@ func (s *Service) listRelations(ctx context.Context, appCode string) ([]relation
|
||||
relationsByType := map[string]relationDTO{}
|
||||
for rows.Next() {
|
||||
var item relationDTO
|
||||
if err := rows.Scan(&item.RelationType, &item.MaxCountPerUser, &item.ApplicationExpireHours, &item.Status, &item.UpdatedAtMS); err != nil {
|
||||
if err := rows.Scan(&item.RelationType, &item.MaxCountPerUser, &item.ApplicationExpireHours, &item.BreakupCostCoins, &item.Status, &item.UpdatedAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.DisplayName = relationLabels[item.RelationType]
|
||||
@ -222,9 +224,9 @@ func (s *Service) ensureDefaultConfig(ctx context.Context, appCode string, nowMs
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO user_cp_relation_configs (
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours,
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
|
||||
status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'active', ?, ?)
|
||||
) VALUES (?, ?, ?, ?, 0, 'active', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`,
|
||||
appCode, relationType, maxCount, defaultExpireHours, nowMs, nowMs,
|
||||
); err != nil {
|
||||
@ -256,6 +258,7 @@ func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
relation_type VARCHAR(32) NOT NULL,
|
||||
max_count_per_user INT NOT NULL,
|
||||
application_expire_hours INT NOT NULL,
|
||||
breakup_cost_coins BIGINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
@ -263,6 +266,11 @@ func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureColumn(ctx, "user_cp_relation_configs", "breakup_cost_coins", `
|
||||
ALTER TABLE user_cp_relation_configs
|
||||
ADD COLUMN breakup_cost_coins BIGINT NOT NULL DEFAULT 0 AFTER application_expire_hours`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS user_cp_level_rules (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
@ -280,6 +288,23 @@ func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ensureColumn(ctx context.Context, tableName string, columnName string, alterSQL string) error {
|
||||
var found string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
LIMIT 1`, tableName, columnName).Scan(&found)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, alterSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeRelations(input []relationDTO) ([]relationDTO, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, fmt.Errorf("%w: 关系配置不能为空", ErrInvalidArgument)
|
||||
@ -305,6 +330,9 @@ func normalizeRelations(input []relationDTO) ([]relationDTO, error) {
|
||||
if relation.ApplicationExpireHours <= 0 {
|
||||
relation.ApplicationExpireHours = defaultExpireHours
|
||||
}
|
||||
if relation.BreakupCostCoins < 0 {
|
||||
return nil, fmt.Errorf("%w: %s解除费用不能小于0", ErrInvalidArgument, relationLabels[relation.RelationType])
|
||||
}
|
||||
relation.Status = defaultString(strings.TrimSpace(relation.Status), statusActive)
|
||||
relation.Levels = normalizeLevelDefaults(relation.Levels)
|
||||
if err := validateLevels(relation.Levels); err != nil {
|
||||
|
||||
@ -63,6 +63,9 @@ type UserCPClient interface {
|
||||
AcceptCPApplication(ctx context.Context, req *userv1.AcceptCPApplicationRequest) (*userv1.AcceptCPApplicationResponse, error)
|
||||
RejectCPApplication(ctx context.Context, req *userv1.RejectCPApplicationRequest) (*userv1.RejectCPApplicationResponse, error)
|
||||
ListCPRelationships(ctx context.Context, req *userv1.ListCPRelationshipsRequest) (*userv1.ListCPRelationshipsResponse, error)
|
||||
PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error)
|
||||
ConfirmBreakCPRelationship(ctx context.Context, req *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error)
|
||||
CancelBreakCPRelationship(ctx context.Context, req *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error)
|
||||
}
|
||||
|
||||
// UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。
|
||||
@ -367,6 +370,18 @@ func (c *grpcUserCPClient) ListCPRelationships(ctx context.Context, req *userv1.
|
||||
return c.client.ListCPRelationships(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCPClient) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
|
||||
return c.client.PrepareBreakCPRelationship(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCPClient) ConfirmBreakCPRelationship(ctx context.Context, req *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error) {
|
||||
return c.client.ConfirmBreakCPRelationship(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCPClient) CancelBreakCPRelationship(ctx context.Context, req *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) {
|
||||
return c.client.CancelBreakCPRelationship(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserDeviceClient) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
|
||||
return c.client.BindPushToken(ctx, req)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ type WalletClient interface {
|
||||
ListVipPackages(ctx context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error)
|
||||
GetMyVip(ctx context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error)
|
||||
PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error)
|
||||
DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error)
|
||||
GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error)
|
||||
TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error)
|
||||
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error)
|
||||
@ -108,6 +109,10 @@ func (c *grpcWalletClient) PurchaseVip(ctx context.Context, req *walletv1.Purcha
|
||||
return c.client.PurchaseVip(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) {
|
||||
return c.client.DebitCPBreakupFee(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||
return c.client.GrantVip(ctx, req)
|
||||
}
|
||||
|
||||
@ -32,8 +32,9 @@ type weeklyStarGiftData struct {
|
||||
}
|
||||
|
||||
type weeklyStarRewardData struct {
|
||||
RankNo int32 `json:"rank_no"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
RankNo int32 `json:"rank_no"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
ResourceGroup *achievementResourceGroupData `json:"resource_group,omitempty"`
|
||||
}
|
||||
|
||||
type weeklyStarEntryData struct {
|
||||
@ -98,8 +99,12 @@ func (h *Handler) getWeeklyStarCurrent(writer http.ResponseWriter, request *http
|
||||
return
|
||||
}
|
||||
users := h.resolveWeeklyStarUsers(request, weeklyStarUserIDs(resp.GetTopEntries(), resp.GetMyEntry()))
|
||||
resourceGroups, ok := h.resolveWeeklyStarResourceGroups(writer, request, resp.GetCycle())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data := weeklyStarCurrentData{
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle()),
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
||||
TopEntries: weeklyStarEntriesFromProto(resp.GetTopEntries(), users),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
}
|
||||
@ -133,8 +138,12 @@ func (h *Handler) listWeeklyStarLeaderboard(writer http.ResponseWriter, request
|
||||
return
|
||||
}
|
||||
users := h.resolveWeeklyStarUsers(request, weeklyStarUserIDs(resp.GetEntries(), nil))
|
||||
resourceGroups, ok := h.resolveWeeklyStarResourceGroups(writer, request, resp.GetCycle())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, weeklyStarLeaderboardData{
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle()),
|
||||
Cycle: weeklyStarCycleFromProto(resp.GetCycle(), resourceGroups),
|
||||
Entries: weeklyStarEntriesFromProto(resp.GetEntries(), users),
|
||||
NextPageToken: resp.GetNextPageToken(),
|
||||
Total: resp.GetTotal(),
|
||||
@ -162,14 +171,20 @@ func (h *Handler) listWeeklyStarHistory(writer http.ResponseWriter, request *htt
|
||||
return
|
||||
}
|
||||
ids := make([]int64, 0)
|
||||
cycleItems := make([]*activityv1.WeeklyStarCycle, 0, len(resp.GetCycles()))
|
||||
for _, cycle := range resp.GetCycles() {
|
||||
ids = append(ids, weeklyStarUserIDs(cycle.GetTopEntries(), nil)...)
|
||||
cycleItems = append(cycleItems, cycle.GetCycle())
|
||||
}
|
||||
users := h.resolveWeeklyStarUsers(request, ids)
|
||||
resourceGroups, ok := h.resolveWeeklyStarResourceGroups(writer, request, cycleItems...)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cycles := make([]weeklyStarHistoryCycleData, 0, len(resp.GetCycles()))
|
||||
for _, item := range resp.GetCycles() {
|
||||
cycles = append(cycles, weeklyStarHistoryCycleData{
|
||||
Cycle: weeklyStarCycleFromProto(item.GetCycle()),
|
||||
Cycle: weeklyStarCycleFromProto(item.GetCycle(), resourceGroups),
|
||||
TopEntries: weeklyStarEntriesFromProto(item.GetTopEntries(), users),
|
||||
})
|
||||
}
|
||||
@ -213,7 +228,13 @@ func (h *Handler) resolveWeeklyStarUsers(request *http.Request, ids []int64) map
|
||||
return users
|
||||
}
|
||||
|
||||
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle) *weeklyStarCycleData {
|
||||
func (h *Handler) resolveWeeklyStarResourceGroups(writer http.ResponseWriter, request *http.Request, cycles ...*activityv1.WeeklyStarCycle) (map[int64]achievementResourceGroupData, bool) {
|
||||
// 周星周期只存奖励资源组 ID,资源名称、类型、素材和数量仍由 wallet-service 资源库统一维护。
|
||||
// gateway 在 App 出口补齐这些展示字段,避免 activity-service 复制资源库快照,也让后台改资源组后 App 读取保持一致。
|
||||
return h.resolveAchievementResourceGroups(writer, request, weeklyStarResourceGroupIDs(cycles...))
|
||||
}
|
||||
|
||||
func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle, resourceGroups map[int64]achievementResourceGroupData) *weeklyStarCycleData {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
@ -235,7 +256,11 @@ func weeklyStarCycleFromProto(item *activityv1.WeeklyStarCycle) *weeklyStarCycle
|
||||
cycle.Gifts = append(cycle.Gifts, weeklyStarGiftData{GiftID: gift.GetGiftId(), SortOrder: gift.GetSortOrder()})
|
||||
}
|
||||
for _, reward := range item.GetRewards() {
|
||||
cycle.Rewards = append(cycle.Rewards, weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()})
|
||||
rewardData := weeklyStarRewardData{RankNo: reward.GetRankNo(), ResourceGroupID: reward.GetResourceGroupId()}
|
||||
if group, ok := resourceGroups[reward.GetResourceGroupId()]; ok {
|
||||
rewardData.ResourceGroup = &group
|
||||
}
|
||||
cycle.Rewards = append(cycle.Rewards, rewardData)
|
||||
}
|
||||
return cycle
|
||||
}
|
||||
@ -273,6 +298,16 @@ func weeklyStarUserIDs(items []*activityv1.WeeklyStarLeaderboardEntry, extra *ac
|
||||
return ids
|
||||
}
|
||||
|
||||
func weeklyStarResourceGroupIDs(cycles ...*activityv1.WeeklyStarCycle) []int64 {
|
||||
ids := make([]int64, 0)
|
||||
for _, cycle := range cycles {
|
||||
for _, reward := range cycle.GetRewards() {
|
||||
ids = append(ids, reward.GetResourceGroupId())
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func queryInt(request *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get(key)))
|
||||
if err != nil || value <= 0 {
|
||||
|
||||
@ -102,6 +102,7 @@ type UserHandlers struct {
|
||||
AcceptCPApplication http.HandlerFunc
|
||||
RejectCPApplication http.HandlerFunc
|
||||
ListCPRelationships http.HandlerFunc
|
||||
BreakCPRelationship http.HandlerFunc
|
||||
GetMyProfile http.HandlerFunc
|
||||
GetMyAppearance http.HandlerFunc
|
||||
GetUserAppearance http.HandlerFunc
|
||||
@ -366,6 +367,7 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/cp/applications/{application_id}/accept", http.MethodPost, h.AcceptCPApplication)
|
||||
r.profile("/cp/applications/{application_id}/reject", http.MethodPost, h.RejectCPApplication)
|
||||
r.profile("/cp/relationships", http.MethodGet, h.ListCPRelationships)
|
||||
r.profile("/cp/relationships/{relationship_id}/break", http.MethodPost, h.BreakCPRelationship)
|
||||
r.profile("/appearance", http.MethodGet, h.GetMyAppearance)
|
||||
r.profile("/users/me/appearance", http.MethodGet, h.GetMyAppearance)
|
||||
r.profile("/users/me", "", h.GetMyProfile)
|
||||
|
||||
@ -567,6 +567,16 @@ type fakeAchievementClient struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeWeeklyStarClient struct {
|
||||
lastCurrent *activityv1.GetWeeklyStarCurrentRequest
|
||||
currentResp *activityv1.GetWeeklyStarCurrentResponse
|
||||
lastLeaderboard *activityv1.ListWeeklyStarLeaderboardRequest
|
||||
leaderboardResp *activityv1.ListWeeklyStarLeaderboardResponse
|
||||
lastHistory *activityv1.ListWeeklyStarHistoryRequest
|
||||
historyResp *activityv1.ListWeeklyStarHistoryResponse
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeBroadcastClient struct {
|
||||
lastRemove *activityv1.RemoveRegionBroadcastMemberRequest
|
||||
err error
|
||||
@ -1159,6 +1169,39 @@ func (f *fakeAchievementClient) SetBadgeDisplay(_ context.Context, req *activity
|
||||
return &activityv1.SetBadgeDisplayResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWeeklyStarClient) GetWeeklyStarCurrent(_ context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error) {
|
||||
f.lastCurrent = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.currentResp != nil {
|
||||
return f.currentResp, nil
|
||||
}
|
||||
return &activityv1.GetWeeklyStarCurrentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWeeklyStarClient) ListWeeklyStarLeaderboard(_ context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
|
||||
f.lastLeaderboard = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.leaderboardResp != nil {
|
||||
return f.leaderboardResp, nil
|
||||
}
|
||||
return &activityv1.ListWeeklyStarLeaderboardResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWeeklyStarClient) ListWeeklyStarHistory(_ context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error) {
|
||||
f.lastHistory = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.historyResp != nil {
|
||||
return f.historyResp, nil
|
||||
}
|
||||
return &activityv1.ListWeeklyStarHistoryResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
|
||||
f.lastRemove = req
|
||||
if f.err != nil {
|
||||
@ -1543,6 +1586,10 @@ func (f *fakeWalletClient) PurchaseVip(_ context.Context, req *walletv1.Purchase
|
||||
return &walletv1.PurchaseVipResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) DebitCPBreakupFee(context.Context, *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) {
|
||||
return &walletv1.DebitCPBreakupFeeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||
f.lastGrantVip = req
|
||||
if f.vipErr != nil {
|
||||
@ -2429,6 +2476,116 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRoomRocketEmbedsRewardResourceGroups(t *testing.T) {
|
||||
queryClient := &fakeRoomQueryClient{rocketResp: &roomv1.GetRoomRocketResponse{
|
||||
Rocket: &roomv1.RoomRocketInfo{
|
||||
Enabled: true,
|
||||
LaunchDelayMs: 1200,
|
||||
Levels: []*roomv1.RoomRocketLevel{{
|
||||
Level: 1,
|
||||
FuelThreshold: 100,
|
||||
InRoomRewards: []*roomv1.RoomRocketRewardItem{{
|
||||
RewardItemId: "in-room-coin",
|
||||
ResourceGroupId: 23,
|
||||
Weight: 100,
|
||||
DisplayName: "1888 Coins",
|
||||
}},
|
||||
}},
|
||||
State: &roomv1.RoomRocketState{
|
||||
CurrentLevel: 1,
|
||||
CurrentFuel: 0,
|
||||
FuelThreshold: 100,
|
||||
Status: "charging",
|
||||
LastRewards: []*roomv1.RoomRocketRewardGrant{{
|
||||
RewardRole: "igniter",
|
||||
UserId: 42,
|
||||
RewardItemId: "chat-bubble",
|
||||
ResourceGroupId: 24,
|
||||
DisplayName: "Blue Bubble",
|
||||
Status: "succeeded",
|
||||
}},
|
||||
},
|
||||
},
|
||||
ServerTimeMs: 1_780_000_000_000,
|
||||
}}
|
||||
walletClient := &fakeWalletClient{resourceGroupsByID: map[int64]*walletv1.ResourceGroup{
|
||||
23: {
|
||||
GroupId: 23,
|
||||
GroupCode: "coin_1888",
|
||||
Name: "Coin Reward",
|
||||
Status: "active",
|
||||
Items: []*walletv1.ResourceGroupItem{{
|
||||
GroupItemId: 2301,
|
||||
ItemType: "wallet_asset",
|
||||
WalletAssetType: "COIN",
|
||||
WalletAssetAmount: 1888,
|
||||
Quantity: 1,
|
||||
}},
|
||||
},
|
||||
24: {
|
||||
GroupId: 24,
|
||||
GroupCode: "bubble_7d",
|
||||
Name: "Chat Bubble",
|
||||
Status: "active",
|
||||
Items: []*walletv1.ResourceGroupItem{{
|
||||
GroupItemId: 2401,
|
||||
ItemType: "resource",
|
||||
ResourceId: 8101,
|
||||
Resource: &walletv1.Resource{
|
||||
ResourceId: 8101,
|
||||
ResourceCode: "bubble_blue",
|
||||
ResourceType: "chat_bubble",
|
||||
Name: "Blue Bubble",
|
||||
Status: "active",
|
||||
AssetUrl: "https://cdn.example/bubble.png",
|
||||
},
|
||||
Quantity: 1,
|
||||
DurationMs: 604800000,
|
||||
}},
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room_1001/rocket", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-room-rocket")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if queryClient.lastRocket == nil || queryClient.lastRocket.GetViewerUserId() != 42 || queryClient.lastRocket.GetRoomId() != "room_1001" {
|
||||
t.Fatalf("GetRoomRocket must forward authenticated viewer and room: %+v", queryClient.lastRocket)
|
||||
}
|
||||
var envelope httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode rocket response failed: %v", err)
|
||||
}
|
||||
data := envelope.Data.(map[string]any)
|
||||
level := data["levels"].([]any)[0].(map[string]any)
|
||||
inRoomReward := level["in_room_rewards"].([]any)[0].(map[string]any)
|
||||
inRoomGroup := inRoomReward["resource_group"].(map[string]any)
|
||||
if inRoomReward["resource_group_id"].(float64) != 23 || inRoomGroup["group_id"].(float64) != 23 {
|
||||
t.Fatalf("rocket reward must embed resource group id 23: %+v", inRoomReward)
|
||||
}
|
||||
coinItem := inRoomGroup["items"].([]any)[0].(map[string]any)
|
||||
if coinItem["item_type"] != "wallet_asset" || coinItem["wallet_asset_type"] != "COIN" || coinItem["wallet_asset_amount"].(float64) != 1888 {
|
||||
t.Fatalf("rocket reward group must expose wallet asset amount: %+v", coinItem)
|
||||
}
|
||||
state := data["state"].(map[string]any)
|
||||
lastReward := state["last_rewards"].([]any)[0].(map[string]any)
|
||||
lastGroup := lastReward["resource_group"].(map[string]any)
|
||||
resourceItem := lastGroup["items"].([]any)[0].(map[string]any)
|
||||
resource := resourceItem["resource"].(map[string]any)
|
||||
if lastReward["resource_group_id"].(float64) != 24 || resource["resource_type"] != "chat_bubble" || resource["asset_url"] != "https://cdn.example/bubble.png" || resourceItem["duration_ms"].(float64) != 604800000 {
|
||||
t.Fatalf("rocket last reward must expose resource material and duration: reward=%+v item=%+v", lastReward, resourceItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsRejectsCountryOutsideUserRegion(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
|
||||
@ -4821,6 +4978,141 @@ func TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
|
||||
rewardResource := &walletv1.Resource{
|
||||
ResourceId: 8101,
|
||||
ResourceCode: "weekly_star_frame",
|
||||
ResourceType: "avatar_frame",
|
||||
Name: "Weekly Star Frame",
|
||||
Status: "active",
|
||||
Grantable: true,
|
||||
WalletAssetType: "FRAME",
|
||||
UsageScopes: []string{"profile"},
|
||||
AssetUrl: "https://cdn.example/weekly-frame.png",
|
||||
PreviewUrl: "https://cdn.example/weekly-frame-preview.png",
|
||||
SortOrder: 10,
|
||||
}
|
||||
weeklyStarClient := &fakeWeeklyStarClient{
|
||||
currentResp: &activityv1.GetWeeklyStarCurrentResponse{
|
||||
Cycle: &activityv1.WeeklyStarCycle{
|
||||
CycleId: "wstar-test",
|
||||
ActivityCode: "weekly_star",
|
||||
RegionId: 686,
|
||||
Title: "Weekly Star",
|
||||
Status: "active",
|
||||
StartMs: 1780848000000,
|
||||
EndMs: 1781452740000,
|
||||
Gifts: []*activityv1.WeeklyStarGift{{
|
||||
GiftId: "308",
|
||||
SortOrder: 1,
|
||||
}},
|
||||
Rewards: []*activityv1.WeeklyStarReward{{
|
||||
RankNo: 1,
|
||||
ResourceGroupId: 23,
|
||||
}},
|
||||
},
|
||||
TopEntries: []*activityv1.WeeklyStarLeaderboardEntry{{
|
||||
RankNo: 1,
|
||||
UserId: 88,
|
||||
Score: 120000,
|
||||
FirstScoredAtMs: 1780849000000,
|
||||
LastScoredAtMs: 1780849900000,
|
||||
}},
|
||||
ServerTimeMs: 1780916324006,
|
||||
},
|
||||
}
|
||||
walletClient := &fakeWalletClient{
|
||||
resourceGroupsByID: map[int64]*walletv1.ResourceGroup{23: {
|
||||
GroupId: 23,
|
||||
GroupCode: "weekly_star_top1",
|
||||
Name: "Top 1 Resource Group",
|
||||
Status: "active",
|
||||
Description: "Top 1 reward materials",
|
||||
Items: []*walletv1.ResourceGroupItem{{
|
||||
GroupItemId: 501,
|
||||
ItemType: "resource",
|
||||
ResourceId: 8101,
|
||||
Resource: rewardResource,
|
||||
Quantity: 1,
|
||||
DurationMs: 7 * 24 * int64(time.Hour/time.Millisecond),
|
||||
SortOrder: 1,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
userClient := &fakeUserProfileClient{
|
||||
regionID: 686,
|
||||
usersByID: map[int64]*userv1.User{
|
||||
88: {
|
||||
UserId: 88,
|
||||
DisplayUserId: "163088",
|
||||
Username: "weekly_ranker",
|
||||
Avatar: "https://cdn.example/ranker.png",
|
||||
RegionId: 686,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, userClient)
|
||||
handler.SetWeeklyStarClient(weeklyStarClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/weekly-star/current", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("weekly star current status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Cycle struct {
|
||||
Rewards []struct {
|
||||
RankNo int32 `json:"rank_no"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
ResourceGroup struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
Name string `json:"name"`
|
||||
Items []struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Resource struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Name string `json:"name"`
|
||||
AssetURL string `json:"asset_url"`
|
||||
} `json:"resource"`
|
||||
} `json:"items"`
|
||||
} `json:"resource_group"`
|
||||
} `json:"rewards"`
|
||||
} `json:"cycle"`
|
||||
TopEntries []struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
} `json:"top_entries"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode weekly star current response failed: %v", err)
|
||||
}
|
||||
if weeklyStarClient.lastCurrent == nil || weeklyStarClient.lastCurrent.GetRegionId() != 686 || weeklyStarClient.lastCurrent.GetUserId() != 42 {
|
||||
t.Fatalf("weekly star current request mismatch: %+v", weeklyStarClient.lastCurrent)
|
||||
}
|
||||
firstReward := envelope.Data.Cycle.Rewards[0]
|
||||
if envelope.Code != httpkit.CodeOK || firstReward.ResourceGroupID != 23 || firstReward.ResourceGroup.GroupID != 23 || firstReward.ResourceGroup.Name != "Top 1 Resource Group" {
|
||||
t.Fatalf("weekly star reward group envelope mismatch: %+v", envelope)
|
||||
}
|
||||
firstItem := firstReward.ResourceGroup.Items[0]
|
||||
if firstItem.ResourceID != 8101 || firstItem.Quantity != 1 || firstItem.DurationMS == 0 || firstItem.Resource.Name != "Weekly Star Frame" || firstItem.Resource.ResourceType != "avatar_frame" || firstItem.Resource.AssetURL == "" {
|
||||
t.Fatalf("weekly star reward item must embed resource material details: %+v", firstItem)
|
||||
}
|
||||
if len(envelope.Data.TopEntries) != 1 || envelope.Data.TopEntries[0].User.Username != "weekly_ranker" {
|
||||
t.Fatalf("weekly star leaderboard user enrichment mismatch: %+v", envelope.Data.TopEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
|
||||
frameResource := &walletv1.Resource{
|
||||
ResourceId: 8101,
|
||||
|
||||
@ -740,7 +740,41 @@ func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Reques
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, roomRocketDataFromProto(resp))
|
||||
resourceGroups, ok := h.resolveRoomRocketResourceGroups(writer, request, resp)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, roomRocketDataFromProto(resp, resourceGroups))
|
||||
}
|
||||
|
||||
// resolveRoomRocketResourceGroups 展开奖励池和最近中奖结果引用的资源组。
|
||||
// room-service 只保存房间状态和奖励资源组 ID;gateway 在 HTTP 出口补齐素材和钱包资产明细,避免 Room Cell 持有展示快照。
|
||||
func (h *Handler) resolveRoomRocketResourceGroups(writer http.ResponseWriter, request *http.Request, resp *roomv1.GetRoomRocketResponse) (map[int64]*roomRocketResourceGroupData, bool) {
|
||||
ids := roomRocketResourceGroupIDs(resp)
|
||||
groups := make(map[int64]*roomRocketResourceGroupData, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return groups, true
|
||||
}
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return nil, false
|
||||
}
|
||||
for _, id := range ids {
|
||||
groupResp, err := h.walletClient.GetResourceGroup(request.Context(), &walletv1.GetResourceGroupRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
GroupId: id,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return nil, false
|
||||
}
|
||||
group := roomRocketResourceGroupFromProto(groupResp.GetGroup())
|
||||
if group != nil && group.GroupID > 0 {
|
||||
groups[group.GroupID] = group
|
||||
}
|
||||
}
|
||||
return groups, true
|
||||
}
|
||||
|
||||
// handleRoomFollow 建立或取消当前用户对房间的关注关系。
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"hyapp/pkg/tencentrtc"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
const joinRoomContributionRankLimit = 10
|
||||
@ -203,11 +204,12 @@ type roomRocketLevelData struct {
|
||||
}
|
||||
|
||||
type roomRocketRewardData struct {
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
ResourceGroup *roomRocketResourceGroupData `json:"resource_group,omitempty"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
}
|
||||
|
||||
type roomRocketStateData struct {
|
||||
@ -241,14 +243,59 @@ type roomRocketPendingLaunchData struct {
|
||||
}
|
||||
|
||||
type roomRocketRewardGrantData struct {
|
||||
RewardRole string `json:"reward_role"`
|
||||
UserID string `json:"user_id"`
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
GrantID string `json:"grant_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
RewardRole string `json:"reward_role"`
|
||||
UserID string `json:"user_id"`
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
ResourceGroup *roomRocketResourceGroupData `json:"resource_group,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
GrantID string `json:"grant_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type roomRocketResourceData struct {
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceCode string `json:"resource_code"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Grantable bool `json:"grantable"`
|
||||
GrantStrategy string `json:"grant_strategy"`
|
||||
WalletAssetType string `json:"wallet_asset_type,omitempty"`
|
||||
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
|
||||
UsageScopes []string `json:"usage_scopes"`
|
||||
AssetURL string `json:"asset_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type roomRocketResourceGroupItemData struct {
|
||||
GroupItemID int64 `json:"group_item_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Resource *roomRocketResourceData `json:"resource,omitempty"`
|
||||
WalletAssetType string `json:"wallet_asset_type,omitempty"`
|
||||
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type roomRocketResourceGroupData struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
GroupCode string `json:"group_code"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
Items []roomRocketResourceGroupItemData `json:"items"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type roomGiftRecipientData struct {
|
||||
@ -418,15 +465,15 @@ func createRoomDataFromProto(resp *roomv1.CreateRoomResponse) createRoomData {
|
||||
}
|
||||
}
|
||||
|
||||
func roomRocketDataFromProto(resp *roomv1.GetRoomRocketResponse) roomRocketData {
|
||||
func roomRocketDataFromProto(resp *roomv1.GetRoomRocketResponse, resourceGroups map[int64]*roomRocketResourceGroupData) roomRocketData {
|
||||
if resp == nil || resp.GetRocket() == nil {
|
||||
return roomRocketData{}
|
||||
}
|
||||
rocket := resp.GetRocket()
|
||||
return roomRocketData{
|
||||
Enabled: rocket.GetEnabled(),
|
||||
Levels: roomRocketLevelsFromProto(rocket.GetLevels()),
|
||||
State: roomRocketStateFromProto(rocket.GetState()),
|
||||
Levels: roomRocketLevelsFromProto(rocket.GetLevels(), resourceGroups),
|
||||
State: roomRocketStateFromProto(rocket.GetState(), resourceGroups),
|
||||
BroadcastScope: rocket.GetBroadcastScope(),
|
||||
LaunchDelayMS: rocket.GetLaunchDelayMs(),
|
||||
BroadcastDelayMS: rocket.GetBroadcastDelayMs(),
|
||||
@ -435,7 +482,7 @@ func roomRocketDataFromProto(resp *roomv1.GetRoomRocketResponse) roomRocketData
|
||||
}
|
||||
}
|
||||
|
||||
func roomRocketLevelsFromProto(levels []*roomv1.RoomRocketLevel) []roomRocketLevelData {
|
||||
func roomRocketLevelsFromProto(levels []*roomv1.RoomRocketLevel, resourceGroups map[int64]*roomRocketResourceGroupData) []roomRocketLevelData {
|
||||
out := make([]roomRocketLevelData, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
out = append(out, roomRocketLevelData{
|
||||
@ -445,20 +492,21 @@ func roomRocketLevelsFromProto(levels []*roomv1.RoomRocketLevel) []roomRocketLev
|
||||
AnimationURL: level.GetAnimationUrl(),
|
||||
LaunchAnimationURL: level.GetLaunchAnimationUrl(),
|
||||
LaunchedImageURL: level.GetLaunchedImageUrl(),
|
||||
InRoomRewards: roomRocketRewardsFromProto(level.GetInRoomRewards()),
|
||||
Top1Rewards: roomRocketRewardsFromProto(level.GetTop1Rewards()),
|
||||
IgniterRewards: roomRocketRewardsFromProto(level.GetIgniterRewards()),
|
||||
InRoomRewards: roomRocketRewardsFromProto(level.GetInRoomRewards(), resourceGroups),
|
||||
Top1Rewards: roomRocketRewardsFromProto(level.GetTop1Rewards(), resourceGroups),
|
||||
IgniterRewards: roomRocketRewardsFromProto(level.GetIgniterRewards(), resourceGroups),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomRocketRewardsFromProto(rewards []*roomv1.RoomRocketRewardItem) []roomRocketRewardData {
|
||||
func roomRocketRewardsFromProto(rewards []*roomv1.RoomRocketRewardItem, resourceGroups map[int64]*roomRocketResourceGroupData) []roomRocketRewardData {
|
||||
out := make([]roomRocketRewardData, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
out = append(out, roomRocketRewardData{
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
ResourceGroup: resourceGroups[reward.GetResourceGroupId()],
|
||||
Weight: reward.GetWeight(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
@ -467,7 +515,7 @@ func roomRocketRewardsFromProto(rewards []*roomv1.RoomRocketRewardItem) []roomRo
|
||||
return out
|
||||
}
|
||||
|
||||
func roomRocketStateFromProto(state *roomv1.RoomRocketState) roomRocketStateData {
|
||||
func roomRocketStateFromProto(state *roomv1.RoomRocketState, resourceGroups map[int64]*roomRocketResourceGroupData) roomRocketStateData {
|
||||
if state == nil {
|
||||
return roomRocketStateData{}
|
||||
}
|
||||
@ -484,7 +532,7 @@ func roomRocketStateFromProto(state *roomv1.RoomRocketState) roomRocketStateData
|
||||
IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()),
|
||||
RocketID: state.GetRocketId(),
|
||||
ConfigVersion: state.GetConfigVersion(),
|
||||
LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards()),
|
||||
LastRewards: roomRocketRewardGrantsFromProto(state.GetLastRewards(), resourceGroups),
|
||||
PendingLaunches: roomRocketPendingLaunchesFromProto(state.GetPendingLaunches()),
|
||||
}
|
||||
}
|
||||
@ -508,7 +556,7 @@ func roomRocketPendingLaunchesFromProto(launches []*roomv1.RoomRocketPendingLaun
|
||||
return out
|
||||
}
|
||||
|
||||
func roomRocketRewardGrantsFromProto(rewards []*roomv1.RoomRocketRewardGrant) []roomRocketRewardGrantData {
|
||||
func roomRocketRewardGrantsFromProto(rewards []*roomv1.RoomRocketRewardGrant, resourceGroups map[int64]*roomRocketResourceGroupData) []roomRocketRewardGrantData {
|
||||
out := make([]roomRocketRewardGrantData, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
out = append(out, roomRocketRewardGrantData{
|
||||
@ -516,6 +564,7 @@ func roomRocketRewardGrantsFromProto(rewards []*roomv1.RoomRocketRewardGrant) []
|
||||
UserID: formatOptionalUserID(reward.GetUserId()),
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
ResourceGroup: resourceGroups[reward.GetResourceGroupId()],
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
GrantID: reward.GetGrantId(),
|
||||
@ -525,6 +574,95 @@ func roomRocketRewardGrantsFromProto(rewards []*roomv1.RoomRocketRewardGrant) []
|
||||
return out
|
||||
}
|
||||
|
||||
func roomRocketResourceGroupIDs(resp *roomv1.GetRoomRocketResponse) []int64 {
|
||||
if resp == nil || resp.GetRocket() == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
ids := make([]int64, 0)
|
||||
add := func(id int64) {
|
||||
if id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for _, level := range resp.GetRocket().GetLevels() {
|
||||
for _, reward := range level.GetInRoomRewards() {
|
||||
add(reward.GetResourceGroupId())
|
||||
}
|
||||
for _, reward := range level.GetTop1Rewards() {
|
||||
add(reward.GetResourceGroupId())
|
||||
}
|
||||
for _, reward := range level.GetIgniterRewards() {
|
||||
add(reward.GetResourceGroupId())
|
||||
}
|
||||
}
|
||||
for _, reward := range resp.GetRocket().GetState().GetLastRewards() {
|
||||
add(reward.GetResourceGroupId())
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func roomRocketResourceGroupFromProto(group *walletv1.ResourceGroup) *roomRocketResourceGroupData {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]roomRocketResourceGroupItemData, 0, len(group.GetItems()))
|
||||
for _, item := range group.GetItems() {
|
||||
items = append(items, roomRocketResourceGroupItemData{
|
||||
GroupItemID: item.GetGroupItemId(),
|
||||
ItemType: item.GetItemType(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Resource: roomRocketResourceFromProto(item.GetResource()),
|
||||
WalletAssetType: item.GetWalletAssetType(),
|
||||
WalletAssetAmount: item.GetWalletAssetAmount(),
|
||||
Quantity: item.GetQuantity(),
|
||||
DurationMS: item.GetDurationMs(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
})
|
||||
}
|
||||
return &roomRocketResourceGroupData{
|
||||
GroupID: group.GetGroupId(),
|
||||
GroupCode: group.GetGroupCode(),
|
||||
Name: group.GetName(),
|
||||
Status: group.GetStatus(),
|
||||
Description: group.GetDescription(),
|
||||
SortOrder: group.GetSortOrder(),
|
||||
Items: items,
|
||||
CreatedAtMS: group.GetCreatedAtMs(),
|
||||
UpdatedAtMS: group.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomRocketResourceFromProto(item *walletv1.Resource) *roomRocketResourceData {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return &roomRocketResourceData{
|
||||
ResourceID: item.GetResourceId(),
|
||||
ResourceCode: item.GetResourceCode(),
|
||||
ResourceType: item.GetResourceType(),
|
||||
Name: item.GetName(),
|
||||
Status: item.GetStatus(),
|
||||
Grantable: item.GetGrantable(),
|
||||
GrantStrategy: item.GetGrantStrategy(),
|
||||
WalletAssetType: item.GetWalletAssetType(),
|
||||
WalletAssetAmount: item.GetWalletAssetAmount(),
|
||||
UsageScopes: item.GetUsageScopes(),
|
||||
AssetURL: item.GetAssetUrl(),
|
||||
PreviewURL: item.GetPreviewUrl(),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
MetadataJSON: item.GetMetadataJson(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
|
||||
if room == nil {
|
||||
return roomListItemData{}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
@ -54,7 +55,9 @@ type cpRelationshipData struct {
|
||||
NeededForNextLevel int64 `json:"needed_for_next_level"`
|
||||
LevelProgressPercent int32 `json:"level_progress_percent"`
|
||||
MaxLevel bool `json:"max_level"`
|
||||
BreakupCostCoins int64 `json:"breakup_cost_coins"`
|
||||
FormedAtMS int64 `json:"formed_at_ms"`
|
||||
EndedAtMS int64 `json:"ended_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
@ -62,6 +65,10 @@ type cpRejectRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type cpBreakRelationshipRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
}
|
||||
|
||||
// listCPApplications 返回当前用户收到或发出的 CP/兄弟/姐妹申请。
|
||||
func (h *Handler) listCPApplications(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userCPClient == nil {
|
||||
@ -182,6 +189,96 @@ func (h *Handler) listCPRelationships(writer http.ResponseWriter, request *http.
|
||||
})
|
||||
}
|
||||
|
||||
// breakCPRelationship 先在 user-service 创建解除占位,再扣钱包,最后确认关系 ended,避免并发请求重复扣费。
|
||||
func (h *Handler) breakCPRelationship(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userCPClient == nil || h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
relationshipID := strings.TrimSpace(request.PathValue("relationship_id"))
|
||||
if relationshipID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusNotFound, httpkit.CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
var body cpBreakRelationshipRequest
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is required")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
meta := httpkit.UserMeta(request, "")
|
||||
prepare, err := h.userCPClient.PrepareBreakCPRelationship(request.Context(), &userv1.PrepareBreakCPRelationshipRequest{
|
||||
Meta: meta,
|
||||
UserId: userID,
|
||||
RelationshipId: relationshipID,
|
||||
CommandId: commandID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
if prepare.GetStatus() == "confirmed" {
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"relationship": cpRelationshipFromProto(prepare.GetRelationship()),
|
||||
"breakup_cost_coins": prepare.GetBreakupCostCoins(),
|
||||
"wallet_transaction_id": prepare.GetWalletTransactionId(),
|
||||
"coin_balance_after": prepare.GetCoinBalanceAfter(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
walletTransactionID := ""
|
||||
coinBalanceAfter := int64(0)
|
||||
cost := prepare.GetBreakupCostCoins()
|
||||
if cost > 0 {
|
||||
walletResp, walletErr := h.walletClient.DebitCPBreakupFee(request.Context(), &walletv1.DebitCPBreakupFeeRequest{
|
||||
CommandId: commandID,
|
||||
AppCode: meta.GetAppCode(),
|
||||
UserId: userID,
|
||||
RelationshipId: relationshipID,
|
||||
RelationType: prepare.GetRelationship().GetRelationType(),
|
||||
Amount: cost,
|
||||
})
|
||||
if walletErr != nil {
|
||||
// 钱包失败表示关系不应解除;取消 pending 占位后把真实钱包错误返回给客户端。
|
||||
_, _ = h.userCPClient.CancelBreakCPRelationship(request.Context(), &userv1.CancelBreakCPRelationshipRequest{
|
||||
Meta: meta,
|
||||
UserId: userID,
|
||||
RelationshipId: relationshipID,
|
||||
CommandId: commandID,
|
||||
Reason: "wallet_debit_failed",
|
||||
})
|
||||
httpkit.WriteRPCError(writer, request, walletErr)
|
||||
return
|
||||
}
|
||||
walletTransactionID = walletResp.GetTransactionId()
|
||||
coinBalanceAfter = walletResp.GetCoinBalanceAfter()
|
||||
}
|
||||
confirmed, err := h.userCPClient.ConfirmBreakCPRelationship(request.Context(), &userv1.ConfirmBreakCPRelationshipRequest{
|
||||
Meta: meta,
|
||||
UserId: userID,
|
||||
RelationshipId: relationshipID,
|
||||
CommandId: commandID,
|
||||
WalletTransactionId: walletTransactionID,
|
||||
PaidCoinAmount: cost,
|
||||
CoinBalanceAfter: coinBalanceAfter,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"relationship": cpRelationshipFromProto(confirmed.GetRelationship()),
|
||||
"breakup_cost_coins": confirmed.GetBreakupCostCoins(),
|
||||
"wallet_transaction_id": confirmed.GetWalletTransactionId(),
|
||||
"coin_balance_after": confirmed.GetCoinBalanceAfter(),
|
||||
})
|
||||
}
|
||||
|
||||
func cpApplicationFromProto(item *userv1.CPApplication) cpApplicationData {
|
||||
if item == nil {
|
||||
return cpApplicationData{}
|
||||
@ -219,7 +316,9 @@ func cpRelationshipFromProto(item *userv1.CPRelationship) cpRelationshipData {
|
||||
NeededForNextLevel: item.GetNeededForNextLevel(),
|
||||
LevelProgressPercent: item.GetLevelProgressPercent(),
|
||||
MaxLevel: item.GetMaxLevel(),
|
||||
BreakupCostCoins: item.GetBreakupCostCoins(),
|
||||
FormedAtMS: item.GetFormedAtMs(),
|
||||
EndedAtMS: item.GetEndedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,9 @@ func TestCPRelationshipFromProtoIncludesLevelProgress(t *testing.T) {
|
||||
NeededForNextLevel: 120,
|
||||
LevelProgressPercent: 40,
|
||||
MaxLevel: false,
|
||||
BreakupCostCoins: 88,
|
||||
FormedAtMs: 1700000000000,
|
||||
EndedAtMs: 1700000002000,
|
||||
UpdatedAtMs: 1700000001000,
|
||||
}
|
||||
|
||||
@ -27,7 +29,9 @@ func TestCPRelationshipFromProtoIncludesLevelProgress(t *testing.T) {
|
||||
got.NextLevelThreshold != 300 ||
|
||||
got.NeededForNextLevel != 120 ||
|
||||
got.LevelProgressPercent != 40 ||
|
||||
got.MaxLevel {
|
||||
got.MaxLevel ||
|
||||
got.BreakupCostCoins != 88 ||
|
||||
got.EndedAtMS != 1700000002000 {
|
||||
t.Fatalf("relationship progress fields not mapped: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
AcceptCPApplication: h.acceptCPApplication,
|
||||
RejectCPApplication: h.rejectCPApplication,
|
||||
ListCPRelationships: h.listCPRelationships,
|
||||
BreakCPRelationship: h.breakCPRelationship,
|
||||
GetMyProfile: h.getMyProfile,
|
||||
GetMyAppearance: h.getMyAppearance,
|
||||
GetUserAppearance: h.getUserAppearance,
|
||||
|
||||
@ -425,6 +425,7 @@ CREATE TABLE IF NOT EXISTS user_cp_relation_configs (
|
||||
relation_type VARCHAR(32) NOT NULL COMMENT '关系类型:cp、brother、sister',
|
||||
max_count_per_user INT NOT NULL COMMENT '每个用户可拥有的该类型 active 关系数量上限',
|
||||
application_expire_hours INT NOT NULL COMMENT '申请过期小时数',
|
||||
breakup_cost_coins BIGINT NOT NULL DEFAULT 0 COMMENT '用户主动解除该类型关系需要支付的金币数',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
@ -487,6 +488,11 @@ CREATE TABLE IF NOT EXISTS user_cp_relationships (
|
||||
source_application_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '建立关系的申请 ID',
|
||||
formed_at_ms BIGINT NOT NULL COMMENT '建立时间,UTC epoch ms',
|
||||
ended_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '解除时间,UTC epoch ms',
|
||||
ended_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '主动解除关系的用户 ID',
|
||||
breakup_cost_coins BIGINT NOT NULL DEFAULT 0 COMMENT '本次解除实际支付金币数',
|
||||
breakup_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '解除关系命令 ID',
|
||||
breakup_wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '解除扣费钱包交易 ID',
|
||||
breakup_coin_balance_after 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, relationship_id),
|
||||
@ -495,6 +501,26 @@ CREATE TABLE IF NOT EXISTS user_cp_relationships (
|
||||
KEY idx_user_cp_rel_user_b (app_code, user_b_id, status, relation_type, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_cp_relationship_breakups (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '解除关系命令 ID',
|
||||
relationship_id VARCHAR(128) NOT NULL COMMENT '关系 ID',
|
||||
requester_user_id BIGINT NOT NULL COMMENT '发起解除的用户 ID',
|
||||
relation_type VARCHAR(32) NOT NULL COMMENT '关系类型:cp、brother、sister',
|
||||
breakup_cost_coins BIGINT NOT NULL DEFAULT 0 COMMENT '创建解除占位时读取到的金币费用',
|
||||
status VARCHAR(32) NOT NULL COMMENT '解除状态:pending、confirmed、canceled',
|
||||
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '解除扣费钱包交易 ID',
|
||||
coin_balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '扣费后金币余额',
|
||||
reason VARCHAR(256) NOT NULL DEFAULT '' COMMENT '取消原因',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
confirmed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '确认解除时间,UTC epoch ms',
|
||||
canceled_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '取消解除时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, command_id),
|
||||
KEY idx_user_cp_breakup_relationship_status (app_code, relationship_id, status, updated_at_ms),
|
||||
KEY idx_user_cp_breakup_requester_status (app_code, requester_user_id, status, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系解除占位表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_cp_gift_event_consumption (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
room_event_id VARCHAR(128) NOT NULL COMMENT 'room outbox 事件 ID',
|
||||
|
||||
@ -15,6 +15,10 @@ const (
|
||||
RelationshipStatusActive = "active"
|
||||
RelationshipStatusEnded = "ended"
|
||||
|
||||
BreakupStatusPending = "pending"
|
||||
BreakupStatusConfirmed = "confirmed"
|
||||
BreakupStatusCanceled = "canceled"
|
||||
|
||||
GiftTypeCodeCP = "cp"
|
||||
|
||||
EventTypeApplicationCreated = "UserCPApplicationCreated"
|
||||
@ -23,6 +27,7 @@ const (
|
||||
EventTypeRelationshipCreated = "UserCPRelationshipCreated"
|
||||
EventTypeRelationshipIntimacyChanged = "UserCPRelationshipIntimacyChanged"
|
||||
EventTypeRelationshipLevelUp = "UserCPRelationshipLevelUp"
|
||||
EventTypeRelationshipEnded = "UserCPRelationshipEnded"
|
||||
)
|
||||
|
||||
// UserProfile 是 CP 申请和关系展示用的用户快照。
|
||||
@ -80,9 +85,28 @@ type Relationship struct {
|
||||
MaxLevel bool
|
||||
SourceApplicationID string
|
||||
FormedAtMS int64
|
||||
EndedAtMS int64
|
||||
BreakupCostCoins int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RelationshipBreakup 是一次解除关系命令的占位事实;pending 阻止同一关系被并发重复扣费。
|
||||
type RelationshipBreakup struct {
|
||||
CommandID string
|
||||
RelationshipID string
|
||||
RequesterUserID int64
|
||||
RelationType string
|
||||
BreakupCostCoins int64
|
||||
Status string
|
||||
WalletTransactionID string
|
||||
CoinBalanceAfter int64
|
||||
Reason string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
ConfirmedAtMS int64
|
||||
CanceledAtMS int64
|
||||
}
|
||||
|
||||
// GiftEvent 是 room outbox 中 RoomGiftSent 转换后的 CP 关系消费输入。
|
||||
type GiftEvent struct {
|
||||
AppCode string
|
||||
|
||||
@ -16,6 +16,9 @@ type Repository interface {
|
||||
AcceptApplication(ctx context.Context, userID int64, applicationID string, nowMs int64) (cpdomain.Application, cpdomain.Relationship, error)
|
||||
RejectApplication(ctx context.Context, userID int64, applicationID string, reason string, nowMs int64) (cpdomain.Application, error)
|
||||
ListRelationships(ctx context.Context, userID int64, relationType string, page int32, pageSize int32) ([]cpdomain.Relationship, int64, error)
|
||||
PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
|
||||
ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error)
|
||||
CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error)
|
||||
ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error)
|
||||
}
|
||||
|
||||
@ -73,6 +76,36 @@ func (s *Service) ListRelationships(ctx context.Context, userID int64, relationT
|
||||
return s.repo.ListRelationships(ctx, userID, normalizeRelationType(relationType), page, pageSize)
|
||||
}
|
||||
|
||||
// PrepareBreakRelationship 创建解除关系占位;占位成功后 gateway 才能去 wallet-service 扣费。
|
||||
func (s *Service) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
return s.repo.PrepareBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), s.nowMs())
|
||||
}
|
||||
|
||||
// ConfirmBreakRelationship 在 wallet-service 扣费成功后确认解除;paidCoinAmount 必须等于 prepare 时冻结的配置费用。
|
||||
func (s *Service) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if paidCoinAmount < 0 {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "paid_coin_amount is invalid")
|
||||
}
|
||||
if paidCoinAmount > 0 && strings.TrimSpace(walletTransactionID) == "" {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required")
|
||||
}
|
||||
return s.repo.ConfirmBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), strings.TrimSpace(walletTransactionID), paidCoinAmount, coinBalanceAfter, s.nowMs())
|
||||
}
|
||||
|
||||
// CancelBreakRelationship 只取消 pending 占位;已经 confirmed 的解除不能通过取消接口回滚。
|
||||
func (s *Service) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string) (cpdomain.RelationshipBreakup, error) {
|
||||
if err := requireBreakCommand(userID, relationshipID, commandID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
return s.repo.CancelBreakRelationship(ctx, userID, strings.TrimSpace(relationshipID), strings.TrimSpace(commandID), reason, s.nowMs())
|
||||
}
|
||||
|
||||
// ConsumeGiftEvent 消费 room-service 已扣费并落房间状态的送礼事实。
|
||||
func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent) (cpdomain.ConsumeResult, error) {
|
||||
event.CPRelationType = normalizeRelationType(event.CPRelationType)
|
||||
@ -100,6 +133,22 @@ func requireUserID(userID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireBreakCommand(userID int64, relationshipID string, commandID string) error {
|
||||
if err := requireUserID(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(relationshipID) == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "relationship_id is required")
|
||||
}
|
||||
if strings.TrimSpace(commandID) == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "command_id is required")
|
||||
}
|
||||
if len(strings.TrimSpace(commandID)) > 128 {
|
||||
return xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDirection(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "outgoing":
|
||||
|
||||
@ -129,8 +129,24 @@ func (r *Repository) AcceptApplication(ctx context.Context, userID int64, applic
|
||||
INSERT INTO user_cp_relationships (
|
||||
app_code, relationship_id, user_a_id, user_b_id, relation_type, status,
|
||||
intimacy_value, level_no, source_application_id,
|
||||
formed_at_ms, ended_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, 0, ?, ?)`,
|
||||
formed_at_ms, ended_at_ms, ended_by_user_id, breakup_cost_coins,
|
||||
breakup_command_id, breakup_wallet_transaction_id, breakup_coin_balance_after,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?, 0, 0, 0, '', '', 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
relation_type = VALUES(relation_type),
|
||||
status = VALUES(status),
|
||||
intimacy_value = 0,
|
||||
level_no = 1,
|
||||
source_application_id = VALUES(source_application_id),
|
||||
formed_at_ms = VALUES(formed_at_ms),
|
||||
ended_at_ms = 0,
|
||||
ended_by_user_id = 0,
|
||||
breakup_cost_coins = 0,
|
||||
breakup_command_id = '',
|
||||
breakup_wallet_transaction_id = '',
|
||||
breakup_coin_balance_after = 0,
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode, relationshipID, userAID, userBID, application.RelationType, cpdomain.RelationshipStatusActive,
|
||||
application.ApplicationID, nowMs, nowMs, nowMs,
|
||||
); err != nil {
|
||||
@ -260,6 +276,196 @@ func (r *Repository) ListRelationships(ctx context.Context, userID int64, relati
|
||||
return relationships, total, nil
|
||||
}
|
||||
|
||||
// PrepareBreakRelationship 为解除关系创建 pending 占位;占位持有 command 幂等事实,但不提前结束关系。
|
||||
func (r *Repository) PrepareBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
if err := ensureDefaultConfigTx(ctx, tx, appCode, nowMs); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
// 所有解除分支先锁关系行,再锁 breakup 行;prepare/confirm/cancel 共享这个顺序以降低死锁概率。
|
||||
relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if err := assertRelationshipParticipant(relationship, userID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if existing, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
} else if found {
|
||||
if err := assertBreakupMatches(existing, userID, relationshipID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if existing.Status == cpdomain.BreakupStatusCanceled {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled")
|
||||
}
|
||||
return existing, relationship, tx.Commit()
|
||||
}
|
||||
if relationship.Status != cpdomain.RelationshipStatusActive {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active")
|
||||
}
|
||||
if pending, found, err := lockPendingBreakupForRelationshipTx(ctx, tx, appCode, relationshipID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
} else if found {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, fmt.Sprintf("cp relationship breakup is pending: %s", pending.CommandID))
|
||||
}
|
||||
cost := relationship.BreakupCostCoins
|
||||
if cost < 0 {
|
||||
cost = 0
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_cp_relationship_breakups (
|
||||
app_code, command_id, relationship_id, requester_user_id, relation_type,
|
||||
breakup_cost_coins, status, wallet_transaction_id, coin_balance_after,
|
||||
reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, '', 0, '', ?, ?, 0, 0)`,
|
||||
appCode, commandID, relationship.RelationshipID, userID, relationship.RelationType,
|
||||
cost, cpdomain.BreakupStatusPending, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
return breakup, relationship, tx.Commit()
|
||||
}
|
||||
|
||||
// ConfirmBreakRelationship 在钱包扣费成功后真正结束关系;重复确认同一 command 只返回已确认结果。
|
||||
func (r *Repository) ConfirmBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, walletTransactionID string, paidCoinAmount int64, coinBalanceAfter int64, nowMs int64) (cpdomain.RelationshipBreakup, cpdomain.Relationship, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if err := assertRelationshipParticipant(relationship, userID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if breakup.Status == cpdomain.BreakupStatusConfirmed {
|
||||
return breakup, relationship, tx.Commit()
|
||||
}
|
||||
if breakup.Status == cpdomain.BreakupStatusCanceled {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship breakup command is canceled")
|
||||
}
|
||||
if relationship.Status != cpdomain.RelationshipStatusActive {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp relationship is not active")
|
||||
}
|
||||
if paidCoinAmount != breakup.BreakupCostCoins {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.Conflict, "cp breakup paid amount mismatch")
|
||||
}
|
||||
if breakup.BreakupCostCoins > 0 && walletTransactionID == "" {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, xerr.New(xerr.InvalidArgument, "wallet_transaction_id is required")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_cp_relationships
|
||||
SET status = ?, ended_at_ms = ?, ended_by_user_id = ?,
|
||||
breakup_cost_coins = ?, breakup_command_id = ?, breakup_wallet_transaction_id = ?,
|
||||
breakup_coin_balance_after = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND relationship_id = ? AND status = ?`,
|
||||
cpdomain.RelationshipStatusEnded, nowMs, userID,
|
||||
breakup.BreakupCostCoins, commandID, walletTransactionID,
|
||||
coinBalanceAfter, nowMs,
|
||||
appCode, relationshipID, cpdomain.RelationshipStatusActive,
|
||||
); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_cp_relationship_breakups
|
||||
SET status = ?, wallet_transaction_id = ?, coin_balance_after = ?,
|
||||
updated_at_ms = ?, confirmed_at_ms = ?
|
||||
WHERE app_code = ? AND command_id = ? AND status = ?`,
|
||||
cpdomain.BreakupStatusConfirmed, walletTransactionID, coinBalanceAfter,
|
||||
nowMs, nowMs, appCode, commandID, cpdomain.BreakupStatusPending,
|
||||
); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
relationship, err = queryRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
payload := map[string]any{"relationship": relationship, "breakup": breakup}
|
||||
if err := insertOutboxTx(ctx, tx, appCode, cpdomain.EventTypeRelationshipEnded, "cp_relationship", userID, payload, nowMs); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, cpdomain.Relationship{}, err
|
||||
}
|
||||
return breakup, relationship, tx.Commit()
|
||||
}
|
||||
|
||||
// CancelBreakRelationship 释放 pending 占位;钱包扣费失败后 gateway 调用它,关系仍保持 active。
|
||||
func (r *Repository) CancelBreakRelationship(ctx context.Context, userID int64, relationshipID string, commandID string, reason string, nowMs int64) (cpdomain.RelationshipBreakup, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
appCode := appcode.FromContext(ctx)
|
||||
relationship, err := lockRelationshipByIDTx(ctx, tx, appCode, userID, relationshipID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
if err := assertRelationshipParticipant(relationship, userID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
breakup, err := lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
if err := assertBreakupMatches(breakup, userID, relationshipID); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
if breakup.Status == cpdomain.BreakupStatusConfirmed {
|
||||
return cpdomain.RelationshipBreakup{}, xerr.New(xerr.Conflict, "cp relationship breakup is confirmed")
|
||||
}
|
||||
if breakup.Status == cpdomain.BreakupStatusPending {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_cp_relationship_breakups
|
||||
SET status = ?, reason = ?, updated_at_ms = ?, canceled_at_ms = ?
|
||||
WHERE app_code = ? AND command_id = ? AND status = ?`,
|
||||
cpdomain.BreakupStatusCanceled, strings.TrimSpace(reason), nowMs, nowMs,
|
||||
appCode, commandID, cpdomain.BreakupStatusPending,
|
||||
); err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
breakup, err = lockBreakupByCommandRequiredTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
}
|
||||
return breakup, tx.Commit()
|
||||
}
|
||||
|
||||
// ConsumeGiftEvent 把房间送礼事实转换为 CP 申请或亲密值增长。
|
||||
func (r *Repository) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent, nowMs int64) (cpdomain.ConsumeResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@ -337,9 +543,9 @@ func ensureDefaultConfigTx(ctx context.Context, tx *sql.Tx, appCode string, nowM
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_cp_relation_configs (
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours,
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
|
||||
status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 24, 'active', ?, ?)
|
||||
) VALUES (?, ?, ?, 24, 0, 'active', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE relation_type = VALUES(relation_type)`,
|
||||
appCode, relationType, maxCount, nowMs, nowMs,
|
||||
); err != nil {
|
||||
@ -450,6 +656,11 @@ func relationshipSelectSQL(suffix string) string {
|
||||
SELECT
|
||||
r.relationship_id, r.relation_type, r.status, r.user_a_id, r.user_b_id,
|
||||
r.intimacy_value, r.level_no, r.source_application_id, r.formed_at_ms, r.updated_at_ms,
|
||||
r.ended_at_ms,
|
||||
CASE WHEN r.status = '` + cpdomain.RelationshipStatusEnded + `'
|
||||
THEN r.breakup_cost_coins
|
||||
ELSE COALESCE(config.breakup_cost_coins, 0)
|
||||
END AS breakup_cost_coins,
|
||||
COALESCE((
|
||||
SELECT current_rule.intimacy_threshold
|
||||
FROM user_cp_level_rules current_rule
|
||||
@ -475,6 +686,8 @@ func relationshipSelectSQL(suffix string) string {
|
||||
JOIN users me ON me.app_code = r.app_code AND me.user_id = ?
|
||||
JOIN users partner ON partner.app_code = r.app_code
|
||||
AND partner.user_id = CASE WHEN r.user_a_id = ? THEN r.user_b_id ELSE r.user_a_id END
|
||||
LEFT JOIN user_cp_relation_configs config
|
||||
ON config.app_code = r.app_code AND config.relation_type = r.relation_type
|
||||
` + suffix
|
||||
}
|
||||
|
||||
@ -495,6 +708,7 @@ func scanRelationship(scanner interface{ Scan(dest ...any) error }) (cpdomain.Re
|
||||
err := scanner.Scan(
|
||||
&relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID,
|
||||
&relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.UpdatedAtMS,
|
||||
&relationship.EndedAtMS, &relationship.BreakupCostCoins,
|
||||
&relationship.CurrentLevelThreshold, &relationship.NextLevelThreshold,
|
||||
&relationship.Me.UserID, &relationship.Me.DisplayUserID, &relationship.Me.Username, &relationship.Me.Avatar,
|
||||
&relationship.Partner.UserID, &relationship.Partner.DisplayUserID, &relationship.Partner.Username, &relationship.Partner.Avatar,
|
||||
@ -553,11 +767,93 @@ func queryRelationshipByIDTx(ctx context.Context, tx *sql.Tx, appCode string, vi
|
||||
return relationship, err
|
||||
}
|
||||
|
||||
func lockRelationshipByIDTx(ctx context.Context, tx *sql.Tx, appCode string, viewerUserID int64, relationshipID string) (cpdomain.Relationship, error) {
|
||||
row := tx.QueryRowContext(ctx, relationshipSelectSQL(`
|
||||
WHERE r.app_code = ? AND r.relationship_id = ?
|
||||
FOR UPDATE`),
|
||||
viewerUserID, viewerUserID, appCode, strings.TrimSpace(relationshipID),
|
||||
)
|
||||
relationship, err := scanRelationship(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return cpdomain.Relationship{}, xerr.New(xerr.NotFound, "cp relationship not found")
|
||||
}
|
||||
return relationship, err
|
||||
}
|
||||
|
||||
func assertRelationshipParticipant(relationship cpdomain.Relationship, userID int64) error {
|
||||
if relationship.UserAID == userID || relationship.UserBID == userID {
|
||||
return nil
|
||||
}
|
||||
return xerr.New(xerr.PermissionDenied, "only relationship user can operate cp relationship")
|
||||
}
|
||||
|
||||
func lockBreakupByCommandRequiredTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, error) {
|
||||
breakup, found, err := lockBreakupByCommandTx(ctx, tx, appCode, commandID)
|
||||
if err != nil {
|
||||
return cpdomain.RelationshipBreakup{}, err
|
||||
}
|
||||
if !found {
|
||||
return cpdomain.RelationshipBreakup{}, xerr.New(xerr.NotFound, "cp relationship breakup not found")
|
||||
}
|
||||
return breakup, nil
|
||||
}
|
||||
|
||||
func lockBreakupByCommandTx(ctx context.Context, tx *sql.Tx, appCode string, commandID string) (cpdomain.RelationshipBreakup, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, breakupSelectSQL(`
|
||||
WHERE app_code = ? AND command_id = ?
|
||||
FOR UPDATE`), appCode, strings.TrimSpace(commandID))
|
||||
breakup, err := scanBreakup(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return cpdomain.RelationshipBreakup{}, false, nil
|
||||
}
|
||||
return breakup, err == nil, err
|
||||
}
|
||||
|
||||
func lockPendingBreakupForRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, relationshipID string) (cpdomain.RelationshipBreakup, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, breakupSelectSQL(`
|
||||
WHERE app_code = ? AND relationship_id = ? AND status = ?
|
||||
ORDER BY created_at_ms ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE`), appCode, strings.TrimSpace(relationshipID), cpdomain.BreakupStatusPending)
|
||||
breakup, err := scanBreakup(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return cpdomain.RelationshipBreakup{}, false, nil
|
||||
}
|
||||
return breakup, err == nil, err
|
||||
}
|
||||
|
||||
func breakupSelectSQL(suffix string) string {
|
||||
return `
|
||||
SELECT command_id, relationship_id, requester_user_id, relation_type,
|
||||
breakup_cost_coins, status, wallet_transaction_id, coin_balance_after,
|
||||
reason, created_at_ms, updated_at_ms, confirmed_at_ms, canceled_at_ms
|
||||
FROM user_cp_relationship_breakups
|
||||
` + suffix
|
||||
}
|
||||
|
||||
func scanBreakup(scanner interface{ Scan(dest ...any) error }) (cpdomain.RelationshipBreakup, error) {
|
||||
var breakup cpdomain.RelationshipBreakup
|
||||
err := scanner.Scan(
|
||||
&breakup.CommandID, &breakup.RelationshipID, &breakup.RequesterUserID, &breakup.RelationType,
|
||||
&breakup.BreakupCostCoins, &breakup.Status, &breakup.WalletTransactionID, &breakup.CoinBalanceAfter,
|
||||
&breakup.Reason, &breakup.CreatedAtMS, &breakup.UpdatedAtMS, &breakup.ConfirmedAtMS, &breakup.CanceledAtMS,
|
||||
)
|
||||
return breakup, err
|
||||
}
|
||||
|
||||
func assertBreakupMatches(breakup cpdomain.RelationshipBreakup, userID int64, relationshipID string) error {
|
||||
if breakup.RequesterUserID != userID || strings.TrimSpace(breakup.RelationshipID) != strings.TrimSpace(relationshipID) {
|
||||
// command_id 是解除动作幂等键,不能被另一个用户或另一个关系复用。
|
||||
return xerr.New(xerr.Conflict, "cp relationship breakup command conflict")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, userAID int64, userBID int64) (cpdomain.Relationship, bool, error) {
|
||||
// active 关系行用 FOR UPDATE 锁住 pair,关系创建和送礼加亲密值都走这把锁。
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT relationship_id, relation_type, status, user_a_id, user_b_id,
|
||||
intimacy_value, level_no, source_application_id, formed_at_ms, updated_at_ms
|
||||
intimacy_value, level_no, source_application_id, formed_at_ms, ended_at_ms, updated_at_ms
|
||||
FROM user_cp_relationships
|
||||
WHERE app_code = ? AND user_a_id = ? AND user_b_id = ? AND status = ?
|
||||
FOR UPDATE`,
|
||||
@ -566,7 +862,7 @@ func lockActiveRelationshipTx(ctx context.Context, tx *sql.Tx, appCode string, u
|
||||
var relationship cpdomain.Relationship
|
||||
err := row.Scan(
|
||||
&relationship.RelationshipID, &relationship.RelationType, &relationship.Status, &relationship.UserAID, &relationship.UserBID,
|
||||
&relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.UpdatedAtMS,
|
||||
&relationship.IntimacyValue, &relationship.Level, &relationship.SourceApplicationID, &relationship.FormedAtMS, &relationship.EndedAtMS, &relationship.UpdatedAtMS,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return cpdomain.Relationship{}, false, nil
|
||||
|
||||
@ -149,6 +149,94 @@ func TestListRelationshipsIncludesServerLevelProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakRelationshipPrepareConfirmIsIdempotent(t *testing.T) {
|
||||
schema := mysqlschema.New(t)
|
||||
repo := New(schema.DB)
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
nowMS := int64(1700000000000)
|
||||
|
||||
seedCPTestUser(t, schema.DB, 5001, "Break Alice", nowMS)
|
||||
seedCPTestUser(t, schema.DB, 5002, "Break Bob", nowMS)
|
||||
seedRelationConfigWithBreakupCost(t, schema.DB, cpdomain.RelationTypeCP, 1, 88, nowMS)
|
||||
seedActiveCPRelationship(t, schema.DB, "break_confirm", cpdomain.RelationTypeCP, 5001, 5002, 20, 1, nowMS)
|
||||
|
||||
breakup, relationship, err := repo.PrepareBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", nowMS+1)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare breakup failed: %v", err)
|
||||
}
|
||||
if breakup.Status != cpdomain.BreakupStatusPending || breakup.BreakupCostCoins != 88 {
|
||||
t.Fatalf("prepare breakup mismatch: %+v", breakup)
|
||||
}
|
||||
if relationship.BreakupCostCoins != 88 || relationship.Status != cpdomain.RelationshipStatusActive {
|
||||
t.Fatalf("relationship cost/status mismatch: %+v", relationship)
|
||||
}
|
||||
|
||||
replayed, _, err := repo.PrepareBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", nowMS+2)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare replay failed: %v", err)
|
||||
}
|
||||
if replayed.CommandID != breakup.CommandID || replayed.Status != cpdomain.BreakupStatusPending {
|
||||
t.Fatalf("prepare replay mismatch: %+v", replayed)
|
||||
}
|
||||
if _, _, err := repo.PrepareBreakRelationship(ctx, 5002, "break_confirm", "break_cmd_2", nowMS+3); xerr.CodeOf(err) != xerr.Conflict {
|
||||
t.Fatalf("concurrent breakup must be rejected, got %v", err)
|
||||
}
|
||||
if _, _, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_bad", 87, 913, nowMS+4); xerr.CodeOf(err) != xerr.Conflict {
|
||||
t.Fatalf("paid amount mismatch must be rejected, got %v", err)
|
||||
}
|
||||
|
||||
confirmed, ended, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_1", 88, 912, nowMS+5)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm breakup failed: %v", err)
|
||||
}
|
||||
if confirmed.Status != cpdomain.BreakupStatusConfirmed || confirmed.WalletTransactionID != "wallet_tx_1" {
|
||||
t.Fatalf("confirmed breakup mismatch: %+v", confirmed)
|
||||
}
|
||||
if ended.Status != cpdomain.RelationshipStatusEnded || ended.EndedAtMS != nowMS+5 || ended.BreakupCostCoins != 88 {
|
||||
t.Fatalf("ended relationship mismatch: %+v", ended)
|
||||
}
|
||||
replayedConfirm, replayedEnded, err := repo.ConfirmBreakRelationship(ctx, 5001, "break_confirm", "break_cmd_1", "wallet_tx_1", 88, 912, nowMS+6)
|
||||
if err != nil {
|
||||
t.Fatalf("confirm replay failed: %v", err)
|
||||
}
|
||||
if replayedConfirm.Status != cpdomain.BreakupStatusConfirmed || replayedEnded.Status != cpdomain.RelationshipStatusEnded {
|
||||
t.Fatalf("confirm replay mismatch: breakup=%+v relationship=%+v", replayedConfirm, replayedEnded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakRelationshipCancelKeepsRelationshipActive(t *testing.T) {
|
||||
schema := mysqlschema.New(t)
|
||||
repo := New(schema.DB)
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
nowMS := int64(1700000000000)
|
||||
|
||||
seedCPTestUser(t, schema.DB, 6001, "Cancel Alice", nowMS)
|
||||
seedCPTestUser(t, schema.DB, 6002, "Cancel Bob", nowMS)
|
||||
seedRelationConfigWithBreakupCost(t, schema.DB, cpdomain.RelationTypeSister, 5, 66, nowMS)
|
||||
seedActiveCPRelationship(t, schema.DB, "break_cancel", cpdomain.RelationTypeSister, 6001, 6002, 20, 1, nowMS)
|
||||
|
||||
if _, _, err := repo.PrepareBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", nowMS+1); err != nil {
|
||||
t.Fatalf("prepare cancel breakup failed: %v", err)
|
||||
}
|
||||
canceled, err := repo.CancelBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", "wallet_debit_failed", nowMS+2)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel breakup failed: %v", err)
|
||||
}
|
||||
if canceled.Status != cpdomain.BreakupStatusCanceled {
|
||||
t.Fatalf("cancel status mismatch: %+v", canceled)
|
||||
}
|
||||
relationships, total, err := repo.ListRelationships(ctx, 6002, cpdomain.RelationTypeSister, 1, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list relationships after cancel failed: %v", err)
|
||||
}
|
||||
if total != 1 || len(relationships) != 1 || relationships[0].Status != cpdomain.RelationshipStatusActive {
|
||||
t.Fatalf("relationship must stay active after cancel, total=%d relationships=%+v", total, relationships)
|
||||
}
|
||||
if _, _, err := repo.ConfirmBreakRelationship(ctx, 6002, "break_cancel", "break_cmd_cancel", "wallet_tx_after_cancel", 66, 934, nowMS+3); xerr.CodeOf(err) != xerr.Conflict {
|
||||
t.Fatalf("canceled breakup must not be confirmed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedCPTestUser(t testing.TB, db *sql.DB, userID int64, username string, nowMS int64) {
|
||||
t.Helper()
|
||||
|
||||
@ -167,18 +255,23 @@ func seedCPTestUser(t testing.TB, db *sql.DB, userID int64, username string, now
|
||||
}
|
||||
|
||||
func seedRelationConfig(t testing.TB, db *sql.DB, relationType string, maxCount int32, nowMS int64) {
|
||||
seedRelationConfigWithBreakupCost(t, db, relationType, maxCount, 0, nowMS)
|
||||
}
|
||||
|
||||
func seedRelationConfigWithBreakupCost(t testing.TB, db *sql.DB, relationType string, maxCount int32, breakupCost int64, nowMS int64) {
|
||||
t.Helper()
|
||||
|
||||
// 容量测试直接写配置表,AcceptApplication 中的默认配置 upsert 不会覆盖已有业务配置。
|
||||
if _, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO user_cp_relation_configs (
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours,
|
||||
app_code, relation_type, max_count_per_user, application_expire_hours, breakup_cost_coins,
|
||||
status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 24, 'active', ?, ?)
|
||||
) VALUES (?, ?, ?, 24, ?, 'active', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
max_count_per_user = VALUES(max_count_per_user),
|
||||
breakup_cost_coins = VALUES(breakup_cost_coins),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appcode.Default, relationType, maxCount, nowMS, nowMS,
|
||||
appcode.Default, relationType, maxCount, breakupCost, nowMS, nowMS,
|
||||
); err != nil {
|
||||
t.Fatalf("seed relation config %s failed: %v", relationType, err)
|
||||
}
|
||||
|
||||
@ -79,6 +79,60 @@ func (s *Server) ListCPRelationships(ctx context.Context, req *userv1.ListCPRela
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// PrepareBreakCPRelationship 创建解除关系占位;gateway 随后按返回费用调用 wallet-service 扣金币。
|
||||
func (s *Server) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.cpSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured"))
|
||||
}
|
||||
breakup, relationship, err := s.cpSvc.PrepareBreakRelationship(ctx, req.GetUserId(), req.GetRelationshipId(), req.GetCommandId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.PrepareBreakCPRelationshipResponse{
|
||||
Relationship: toProtoCPRelationship(relationship),
|
||||
CommandId: breakup.CommandID,
|
||||
Status: breakup.Status,
|
||||
BreakupCostCoins: breakup.BreakupCostCoins,
|
||||
WalletTransactionId: breakup.WalletTransactionID,
|
||||
CoinBalanceAfter: breakup.CoinBalanceAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfirmBreakCPRelationship 在钱包扣费成功后确认解除,关系状态和 outbox 在同一事务提交。
|
||||
func (s *Server) ConfirmBreakCPRelationship(ctx context.Context, req *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.cpSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured"))
|
||||
}
|
||||
breakup, relationship, err := s.cpSvc.ConfirmBreakRelationship(ctx, req.GetUserId(), req.GetRelationshipId(), req.GetCommandId(), req.GetWalletTransactionId(), req.GetPaidCoinAmount(), req.GetCoinBalanceAfter())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.ConfirmBreakCPRelationshipResponse{
|
||||
Relationship: toProtoCPRelationship(relationship),
|
||||
BreakupCostCoins: breakup.BreakupCostCoins,
|
||||
WalletTransactionId: breakup.WalletTransactionID,
|
||||
CoinBalanceAfter: breakup.CoinBalanceAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelBreakCPRelationship 取消还未扣费成功的解除占位,避免钱包失败后关系被其他请求长期阻塞。
|
||||
func (s *Server) CancelBreakCPRelationship(ctx context.Context, req *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
if s.cpSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured"))
|
||||
}
|
||||
breakup, err := s.cpSvc.CancelBreakRelationship(ctx, req.GetUserId(), req.GetRelationshipId(), req.GetCommandId(), req.GetReason())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.CancelBreakCPRelationshipResponse{
|
||||
CommandId: breakup.CommandID,
|
||||
Status: breakup.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConsumeRoomGiftCPEvent 由 user-service 内部 MQ worker 调用,把 RoomGiftSent 转为 CP 申请或亲密值增长。
|
||||
func (s *Server) ConsumeRoomGiftCPEvent(ctx context.Context, req *userv1.ConsumeRoomGiftCPEventRequest) (*userv1.ConsumeRoomGiftCPEventResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
@ -154,6 +208,8 @@ func toProtoCPRelationship(relationship cpdomain.Relationship) *userv1.CPRelatio
|
||||
NeededForNextLevel: relationship.NeededForNextLevel,
|
||||
LevelProgressPercent: relationship.LevelProgressPercent,
|
||||
MaxLevel: relationship.MaxLevel,
|
||||
BreakupCostCoins: relationship.BreakupCostCoins,
|
||||
EndedAtMs: relationship.EndedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -668,6 +668,24 @@ type PurchaseVipReceipt struct {
|
||||
RewardItems []VipRewardItem
|
||||
}
|
||||
|
||||
// CPBreakupFeeCommand 是解除 CP/兄弟/姐妹关系时的专用金币扣费命令。
|
||||
type CPBreakupFeeCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
UserID int64
|
||||
RelationshipID string
|
||||
RelationType string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// CPBreakupFeeReceipt 返回解除关系扣费流水和 COIN 账后余额。
|
||||
type CPBreakupFeeReceipt struct {
|
||||
TransactionID string
|
||||
CoinSpent int64
|
||||
CoinBalanceAfter int64
|
||||
Balance AssetBalance
|
||||
}
|
||||
|
||||
// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。
|
||||
type GrantVipCommand struct {
|
||||
AppCode string
|
||||
|
||||
@ -58,6 +58,7 @@ type Repository interface {
|
||||
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
|
||||
GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error)
|
||||
PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error)
|
||||
DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error)
|
||||
GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error)
|
||||
ListAdminVipLevels(ctx context.Context) ([]ledger.VipLevel, error)
|
||||
UpdateAdminVipLevels(ctx context.Context, levels []ledger.AdminVipLevelCommand, operatorUserID int64) ([]ledger.VipLevel, error)
|
||||
@ -961,6 +962,27 @@ func (s *Service) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCom
|
||||
return s.repository.PurchaseVip(ctx, command)
|
||||
}
|
||||
|
||||
// DebitCPBreakupFee 扣除解除关系费用;关系是否可解除仍由 user-service 的 pending/confirm 流程决定。
|
||||
func (s *Service) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) {
|
||||
if command.CommandID == "" || command.UserID <= 0 || strings.TrimSpace(command.RelationshipID) == "" || strings.TrimSpace(command.RelationType) == "" {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee command is incomplete")
|
||||
}
|
||||
if len(command.CommandID) > 128 {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||||
}
|
||||
if command.Amount <= 0 {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InvalidArgument, "cp breakup fee amount must be positive")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
command.RelationshipID = strings.TrimSpace(command.RelationshipID)
|
||||
command.RelationType = strings.ToLower(strings.TrimSpace(command.RelationType))
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.DebitCPBreakupFee(ctx, command)
|
||||
}
|
||||
|
||||
// GrantVip 是活动和后台赠送 VIP 的统一入口;购买仍走 PurchaseVip 完成扣费。
|
||||
func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand) (ledger.GrantVipReceipt, error) {
|
||||
if command.CommandID == "" || command.TargetUserID <= 0 || command.Level <= 0 {
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
package mysql_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
"hyapp/services/wallet-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
func TestDebitCPBreakupFeeDebitsCoinAndReplaysIdempotently(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t)
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repo.SetBalance(7001, 1000)
|
||||
|
||||
command := ledger.CPBreakupFeeCommand{
|
||||
AppCode: appcode.Default,
|
||||
CommandID: "cp_break_fee_1",
|
||||
UserID: 7001,
|
||||
RelationshipID: "cp_rel:7001:7002",
|
||||
RelationType: "cp",
|
||||
Amount: 88,
|
||||
}
|
||||
first, err := repo.DebitCPBreakupFee(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatalf("debit cp breakup fee failed: %v", err)
|
||||
}
|
||||
if first.CoinSpent != 88 || first.CoinBalanceAfter != 912 || first.Balance.AvailableAmount != 912 {
|
||||
t.Fatalf("receipt mismatch: %+v", first)
|
||||
}
|
||||
if repo.CountRows("wallet_transactions", "command_id = ?", command.CommandID) != 1 {
|
||||
t.Fatalf("expected one wallet transaction")
|
||||
}
|
||||
if repo.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID) != 1 {
|
||||
t.Fatalf("expected one wallet entry")
|
||||
}
|
||||
|
||||
second, err := repo.DebitCPBreakupFee(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatalf("debit cp breakup fee replay failed: %v", err)
|
||||
}
|
||||
if second.TransactionID != first.TransactionID || second.CoinBalanceAfter != first.CoinBalanceAfter {
|
||||
t.Fatalf("idempotent replay mismatch: first=%+v second=%+v", first, second)
|
||||
}
|
||||
if repo.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID) != 1 {
|
||||
t.Fatalf("idempotent replay must not create another entry")
|
||||
}
|
||||
|
||||
command.Amount = 89
|
||||
if _, err := repo.DebitCPBreakupFee(ctx, command); xerr.CodeOf(err) != xerr.LedgerConflict {
|
||||
t.Fatalf("same command with different amount must conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitCPBreakupFeeRejectsInsufficientBalance(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t)
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repo.SetBalance(7101, 10)
|
||||
|
||||
_, err := repo.DebitCPBreakupFee(ctx, ledger.CPBreakupFeeCommand{
|
||||
AppCode: appcode.Default,
|
||||
CommandID: "cp_break_fee_insufficient",
|
||||
UserID: 7101,
|
||||
RelationshipID: "cp_rel:7101:7102",
|
||||
RelationType: "sister",
|
||||
Amount: 20,
|
||||
})
|
||||
if xerr.CodeOf(err) != xerr.InsufficientBalance {
|
||||
t.Fatalf("insufficient balance must be rejected, got %v", err)
|
||||
}
|
||||
if repo.CountRows("wallet_transactions", "command_id = ?", "cp_break_fee_insufficient") != 0 {
|
||||
t.Fatalf("insufficient balance must not create transaction")
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,7 @@ const (
|
||||
bizTypeGameRefund = "game_refund"
|
||||
bizTypeGameReverse = "game_reverse"
|
||||
bizTypeHostSalarySettlement = "host_salary_settlement"
|
||||
bizTypeCPBreakupFee = "cp_breakup_fee"
|
||||
outboxStatusPending = "pending"
|
||||
outboxStatusDelivering = "delivering"
|
||||
outboxStatusDelivered = "delivered"
|
||||
@ -786,6 +787,98 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
||||
return balance, transactionID, nil
|
||||
}
|
||||
|
||||
// DebitCPBreakupFee 在钱包账本中扣除解除关系费用;关系状态不在 wallet 中修改,避免 wallet 反向拥有用户关系事实。
|
||||
func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
requestHash := cpBreakupFeeRequestHash(command)
|
||||
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCPBreakupFee); err != nil || exists {
|
||||
if err != nil || !exists {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin)
|
||||
if balanceErr != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, balanceErr
|
||||
}
|
||||
return ledger.CPBreakupFeeReceipt{
|
||||
TransactionID: txRow.TransactionID,
|
||||
CoinSpent: command.Amount,
|
||||
CoinBalanceAfter: balance.AvailableAmount,
|
||||
Balance: balance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
|
||||
if err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
if account.AvailableAmount < command.Amount {
|
||||
return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
balanceAfter := account.AvailableAmount - command.Amount
|
||||
metadata := cpBreakupFeeMetadata{
|
||||
AppCode: command.AppCode,
|
||||
UserID: command.UserID,
|
||||
RelationshipID: command.RelationshipID,
|
||||
RelationType: command.RelationType,
|
||||
Amount: command.Amount,
|
||||
CoinBalanceAfter: balanceAfter,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCPBreakupFee, requestHash, command.RelationshipID, metadata, nowMs); err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
balance := ledger.AssetBalance{
|
||||
AppCode: command.AppCode,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableAmount: balanceAfter,
|
||||
FrozenAmount: account.FrozenAmount,
|
||||
Version: account.Version + 1,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableDelta: -command.Amount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: balance.AvailableAmount,
|
||||
FrozenAfter: balance.FrozenAmount,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
|
||||
}); err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ledger.CPBreakupFeeReceipt{}, err
|
||||
}
|
||||
return ledger.CPBreakupFeeReceipt{
|
||||
TransactionID: transactionID,
|
||||
CoinSpent: command.Amount,
|
||||
CoinBalanceAfter: balance.AvailableAmount,
|
||||
Balance: balance,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。
|
||||
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@ -2624,6 +2717,15 @@ type adminCreditMetadata struct {
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
|
||||
type cpBreakupFeeMetadata struct {
|
||||
AppCode string `json:"app_code"`
|
||||
UserID int64 `json:"user_id"`
|
||||
RelationshipID string `json:"relationship_id"`
|
||||
RelationType string `json:"relation_type"`
|
||||
Amount int64 `json:"amount"`
|
||||
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
||||
}
|
||||
|
||||
type taskRewardMetadata struct {
|
||||
AppCode string `json:"app_code"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
@ -3476,6 +3578,17 @@ func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string {
|
||||
))
|
||||
}
|
||||
|
||||
func cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string {
|
||||
// 解除扣费幂等覆盖关系、关系类型、用户和费用;同 command_id 换任何字段都必须拒绝。
|
||||
return stableHash(fmt.Sprintf("cp_breakup_fee|%s|%d|%s|%s|%d",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.UserID,
|
||||
strings.TrimSpace(command.RelationshipID),
|
||||
strings.ToLower(strings.TrimSpace(command.RelationType)),
|
||||
command.Amount,
|
||||
))
|
||||
}
|
||||
|
||||
func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
|
||||
return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
|
||||
@ -493,6 +493,28 @@ func (s *Server) PurchaseVip(ctx context.Context, req *walletv1.PurchaseVipReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DebitCPBreakupFee 扣除解除 CP/兄弟/姐妹关系费用;关系状态确认由 user-service 负责。
|
||||
func (s *Server) DebitCPBreakupFee(ctx context.Context, req *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
receipt, err := s.svc.DebitCPBreakupFee(ctx, ledger.CPBreakupFeeCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
UserID: req.GetUserId(),
|
||||
RelationshipID: req.GetRelationshipId(),
|
||||
RelationType: req.GetRelationType(),
|
||||
Amount: req.GetAmount(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.DebitCPBreakupFeeResponse{
|
||||
TransactionId: receipt.TransactionID,
|
||||
CoinSpent: receipt.CoinSpent,
|
||||
CoinBalanceAfter: receipt.CoinBalanceAfter,
|
||||
Balance: balanceToProto(receipt.Balance),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GrantVip 统一处理活动和后台赠送 VIP。
|
||||
func (s *Server) GrantVip(ctx context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user