cp 火箭问题修复

This commit is contained in:
zhx 2026-06-08 19:39:22 +08:00
parent d4eef547a8
commit 40157ef64b
32 changed files with 3248 additions and 784 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -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` | 区域群 | 关系建立后发送区域播报 |
| 无 | 无 | 解除关系成功不发 IMApp 根据解除接口返回刷新 |
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`,失败可重试。

View File

@ -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 或区域 IMFlutter 使用接口返回刷新资料卡和关系列表。
## 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

View File

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

View File

@ -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`
## 高频处理

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 只保存房间状态和奖励资源组 IDgateway 在 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 建立或取消当前用户对房间的关注关系。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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