撤销资源组
This commit is contained in:
parent
9c547faf0e
commit
6fd0f03895
File diff suppressed because it is too large
Load Diff
@ -494,6 +494,9 @@ message ResourceGrant {
|
||||
repeated ResourceGrantItem items = 11;
|
||||
int64 created_at_ms = 12;
|
||||
int64 updated_at_ms = 13;
|
||||
int64 revoked_at_ms = 14;
|
||||
int64 revoked_by_user_id = 15;
|
||||
string revoke_reason = 16;
|
||||
}
|
||||
|
||||
message ResourceGroupItemInput {
|
||||
@ -793,6 +796,14 @@ message GrantResourceGroupRequest {
|
||||
string grant_source = 7;
|
||||
}
|
||||
|
||||
message RevokeResourceGrantRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string grant_id = 3;
|
||||
string reason = 4;
|
||||
int64 operator_user_id = 5;
|
||||
}
|
||||
|
||||
message ResourceGrantResponse {
|
||||
ResourceGrant grant = 1;
|
||||
}
|
||||
@ -1989,6 +2000,7 @@ service WalletService {
|
||||
rpc UpsertGiftTypeConfig(UpsertGiftTypeConfigRequest) returns (GiftTypeConfigResponse);
|
||||
rpc GrantResource(GrantResourceRequest) returns (ResourceGrantResponse);
|
||||
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
|
||||
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
|
||||
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
|
||||
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
|
||||
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);
|
||||
|
||||
@ -232,6 +232,7 @@ const (
|
||||
WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig"
|
||||
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
|
||||
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
|
||||
WalletService_RevokeResourceGrant_FullMethodName = "/hyapp.wallet.v1.WalletService/RevokeResourceGrant"
|
||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource"
|
||||
@ -325,6 +326,7 @@ type WalletServiceClient interface {
|
||||
UpsertGiftTypeConfig(ctx context.Context, in *UpsertGiftTypeConfigRequest, opts ...grpc.CallOption) (*GiftTypeConfigResponse, error)
|
||||
GrantResource(ctx context.Context, in *GrantResourceRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||
RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
|
||||
ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
|
||||
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
|
||||
UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, error)
|
||||
@ -699,6 +701,16 @@ func (c *walletServiceClient) GrantResourceGroup(ctx context.Context, in *GrantR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) RevokeResourceGrant(ctx context.Context, in *RevokeResourceGrantRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResourceGrantResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_RevokeResourceGrant_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListUserResourcesResponse)
|
||||
@ -1276,6 +1288,7 @@ type WalletServiceServer interface {
|
||||
UpsertGiftTypeConfig(context.Context, *UpsertGiftTypeConfigRequest) (*GiftTypeConfigResponse, error)
|
||||
GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error)
|
||||
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
|
||||
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
|
||||
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
|
||||
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
|
||||
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, error)
|
||||
@ -1433,6 +1446,9 @@ func (UnimplementedWalletServiceServer) GrantResource(context.Context, *GrantRes
|
||||
func (UnimplementedWalletServiceServer) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GrantResourceGroup not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RevokeResourceGrant not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListUserResources not implemented")
|
||||
}
|
||||
@ -2174,6 +2190,24 @@ func _WalletService_GrantResourceGroup_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_RevokeResourceGrant_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RevokeResourceGrantRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).RevokeResourceGrant(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_RevokeResourceGrant_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).RevokeResourceGrant(ctx, req.(*RevokeResourceGrantRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListUserResourcesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -3277,6 +3311,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GrantResourceGroup",
|
||||
Handler: _WalletService_GrantResourceGroup_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RevokeResourceGrant",
|
||||
Handler: _WalletService_RevokeResourceGrant_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListUserResources",
|
||||
Handler: _WalletService_ListUserResources_Handler,
|
||||
|
||||
@ -288,6 +288,7 @@
|
||||
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
|
||||
| `resource-grant:view` | `menu` | 资源赠送记录 |
|
||||
| `resource-grant:create` | `button` | 给用户赠送资源或资源组 |
|
||||
| `resource-grant:revoke` | `button` | 撤销成功的资源组赠送 |
|
||||
| `gift:view` | `menu` | 礼物列表 |
|
||||
| `gift:create` | `button` | 从资源库选择资源新增礼物 |
|
||||
| `gift:update` | `button` | 编辑礼物、资源绑定和价格 |
|
||||
|
||||
@ -29,6 +29,7 @@ type Client interface {
|
||||
UpsertGiftTypeConfig(ctx context.Context, req *walletv1.UpsertGiftTypeConfigRequest) (*walletv1.GiftTypeConfigResponse, error)
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error)
|
||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||
@ -139,6 +140,10 @@ func (c *GRPCClient) GrantResourceGroup(ctx context.Context, req *walletv1.Grant
|
||||
return c.client.GrantResourceGroup(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
return c.client.RevokeResourceGrant(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
|
||||
return c.client.EquipUserResource(ctx, req)
|
||||
}
|
||||
|
||||
@ -139,6 +139,9 @@ type grantDTO struct {
|
||||
Reason string `json:"reason"`
|
||||
OperatorUserID int64 `json:"operatorUserId"`
|
||||
Operator *operatorDTO `json:"operator,omitempty"`
|
||||
RevokedAtMS int64 `json:"revokedAtMs"`
|
||||
RevokedByUserID int64 `json:"revokedByUserId,string"`
|
||||
RevokeReason string `json:"revokeReason"`
|
||||
Items []grantItemDTO `json:"items"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
@ -436,6 +439,9 @@ func grantFromProto(grant *walletv1.ResourceGrant) grantDTO {
|
||||
Status: grant.GetStatus(),
|
||||
Reason: grant.GetReason(),
|
||||
OperatorUserID: grant.GetOperatorUserId(),
|
||||
RevokedAtMS: grant.GetRevokedAtMs(),
|
||||
RevokedByUserID: grant.GetRevokedByUserId(),
|
||||
RevokeReason: grant.GetRevokeReason(),
|
||||
Items: items,
|
||||
CreatedAtMS: grant.GetCreatedAtMs(),
|
||||
UpdatedAtMS: grant.GetUpdatedAtMs(),
|
||||
|
||||
@ -601,6 +601,28 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
|
||||
response.Created(c, grant)
|
||||
}
|
||||
|
||||
func (h *Handler) RevokeResourceGrant(c *gin.Context) {
|
||||
grantID := strings.TrimSpace(c.Param("grant_id"))
|
||||
if grantID == "" {
|
||||
response.BadRequest(c, "资源赠送记录不存在")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.RevokeResourceGrant(c.Request.Context(), &walletv1.RevokeResourceGrantRequest{
|
||||
RequestId: middleware.CurrentRequestID(c),
|
||||
AppCode: appctx.FromContext(c.Request.Context()),
|
||||
GrantId: grantID,
|
||||
Reason: "admin revoke resource grant",
|
||||
OperatorUserId: actorID(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
grant := grantFromProto(resp.GetGrant())
|
||||
h.auditLog(c, "revoke-resource-grant", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
|
||||
response.OK(c, grant)
|
||||
}
|
||||
|
||||
func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
|
||||
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
|
||||
if keyword == "" {
|
||||
|
||||
@ -43,6 +43,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
|
||||
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
|
||||
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
|
||||
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
|
||||
protected.GET("/admin/resource-grants/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
|
||||
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "资源组更新", Code: "resource-group:update", Kind: "button"},
|
||||
{Name: "资源赠送查看", Code: "resource-grant:view", Kind: "menu"},
|
||||
{Name: "资源赠送创建", Code: "resource-grant:create", Kind: "button"},
|
||||
{Name: "资源赠送撤销", Code: "resource-grant:revoke", Kind: "button"},
|
||||
{Name: "礼物查看", Code: "gift:view", Kind: "menu"},
|
||||
{Name: "礼物创建", Code: "gift:create", Kind: "button"},
|
||||
{Name: "礼物更新", Code: "gift:update", Kind: "button"},
|
||||
@ -534,7 +535,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"resource:view", "resource:create", "resource:update",
|
||||
"resource-shop:view", "resource-shop:update",
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
|
||||
"emoji-pack:view", "emoji-pack:create",
|
||||
"country:view", "country:create", "country:update", "country:status",
|
||||
@ -637,7 +638,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"resource:view", "resource:create", "resource:update",
|
||||
"resource-shop:view", "resource-shop:update",
|
||||
"resource-group:view", "resource-group:create", "resource-group:update",
|
||||
"resource-grant:view", "resource-grant:create",
|
||||
"resource-grant:view", "resource-grant:create", "resource-grant:revoke",
|
||||
"gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
|
||||
"emoji-pack:view", "emoji-pack:create",
|
||||
"upload:create",
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 资源赠送撤销会触发钱包扣回和权益失效,必须独立成按钮权限,不能复用创建权限。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('资源赠送撤销', 'resource-grant:revoke', 'button', '允许撤销成功的资源组赠送', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
|
||||
AND admin_permission.code = 'resource-grant:revoke';
|
||||
@ -1140,6 +1140,9 @@ CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
group_snapshot_json JSON NULL COMMENT '资源分组快照 JSON',
|
||||
reason VARCHAR(256) NOT NULL COMMENT '原因',
|
||||
operator_user_id BIGINT NOT NULL COMMENT '操作人用户 ID',
|
||||
revoked_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤销时间,UTC epoch ms',
|
||||
revoked_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '撤销操作人用户 ID',
|
||||
revoke_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',
|
||||
PRIMARY KEY (app_code, grant_id),
|
||||
|
||||
@ -32,6 +32,7 @@ const (
|
||||
GrantSourceResourceShop = "resource_shop"
|
||||
GrantSourceGameRobotInit = "game_robot_init"
|
||||
GrantStatusDone = "succeeded"
|
||||
GrantStatusRevoked = "revoked"
|
||||
ResultWalletCredit = "wallet_credit"
|
||||
ResultEntitlement = "entitlement"
|
||||
|
||||
|
||||
@ -24,6 +24,9 @@ type ResourceGrant struct {
|
||||
Status string
|
||||
Reason string
|
||||
OperatorUserID int64
|
||||
RevokedAtMS int64
|
||||
RevokedByUserID int64
|
||||
RevokeReason string
|
||||
Items []ResourceGrantItem
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
@ -51,6 +54,14 @@ type GrantResourceGroupCommand struct {
|
||||
GrantSource string
|
||||
}
|
||||
|
||||
type RevokeResourceGrantCommand struct {
|
||||
AppCode string
|
||||
RequestID string
|
||||
GrantID string
|
||||
Reason string
|
||||
OperatorUserID int64
|
||||
}
|
||||
|
||||
type ListResourceGrantsQuery struct {
|
||||
AppCode string
|
||||
TargetUserID int64
|
||||
|
||||
@ -165,6 +165,7 @@ type ResourceCatalogStore interface {
|
||||
type ResourceGrantStore interface {
|
||||
GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error)
|
||||
GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (resourcedomain.ResourceGrant, error)
|
||||
RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error)
|
||||
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
|
||||
}
|
||||
|
||||
|
||||
@ -190,6 +190,15 @@ func (s *Service) GrantResourceGroup(ctx context.Context, command resourcedomain
|
||||
return s.repository.GrantResourceGroup(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error) {
|
||||
if s.repository == nil {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.RevokeResourceGrant(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
|
||||
if s.repository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
|
||||
@ -3944,6 +3944,254 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement 锁定撤销只按原发放明细反向处理,不受资源组后续配置变化影响。
|
||||
func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||
ResourceCode: "badge_revoke_vip",
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
Name: "Revoke VIP Badge",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||
UsageScopes: []string{"profile"},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create revoke badge resource failed: %v", err)
|
||||
}
|
||||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||||
GroupCode: "revoke_pack",
|
||||
Name: "Revoke Pack",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Items: []resourcedomain.ResourceGroupItemInput{
|
||||
{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1},
|
||||
{ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2},
|
||||
},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create revoke resource group failed: %v", err)
|
||||
}
|
||||
grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
|
||||
CommandID: "cmd-grant-revoke-pack",
|
||||
TargetUserID: 42101,
|
||||
GroupID: group.GroupID,
|
||||
Reason: "test revoke group",
|
||||
OperatorUserID: 90001,
|
||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GrantResourceGroup for revoke failed: %v", err)
|
||||
}
|
||||
entitlementID := entitlementIDFromGrant(t, grant)
|
||||
|
||||
revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-pack",
|
||||
GrantID: grant.GrantID,
|
||||
Reason: "test revoke",
|
||||
OperatorUserID: 90002,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeResourceGrant failed: %v", err)
|
||||
}
|
||||
if revoked.Status != resourcedomain.GrantStatusRevoked || revoked.RevokedAtMS == 0 || revoked.RevokedByUserID != 90002 || revoked.RevokeReason != "test revoke" {
|
||||
t.Fatalf("revoked grant metadata mismatch: %+v", revoked)
|
||||
}
|
||||
balances, err := svc.GetBalances(ctx, 42101, []string{ledger.AssetCoin})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalances after revoke failed: %v", err)
|
||||
}
|
||||
if balanceAmount(balances, ledger.AssetCoin) != 0 {
|
||||
t.Fatalf("revoked coin balance mismatch: %+v", balances)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 {
|
||||
t.Fatalf("revoke should write one reverse coin entry, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 {
|
||||
t.Fatalf("revoke should write one reverse wallet transaction, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 {
|
||||
t.Fatalf("revoke should mark entitlement unavailable, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42101), entitlementID); got != 0 {
|
||||
t.Fatalf("revoke should remove equipment row, got %d", got)
|
||||
}
|
||||
|
||||
again, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-pack-again",
|
||||
GrantID: grant.GrantID,
|
||||
Reason: "test revoke again",
|
||||
OperatorUserID: 90003,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeResourceGrant retry failed: %v", err)
|
||||
}
|
||||
if again.Status != resourcedomain.GrantStatusRevoked || again.RevokedByUserID != 90002 {
|
||||
t.Fatalf("idempotent revoke should keep first metadata: %+v", again)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 {
|
||||
t.Fatalf("idempotent revoke must not double debit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient 验证余额不足时整个撤销事务回滚,权益和发放记录仍保持成功状态。
|
||||
func TestRevokeResourceGroupGrantFailsWhenBalanceInsufficient(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||
ResourceCode: "badge_revoke_insufficient",
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
Name: "Revoke Insufficient Badge",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||
UsageScopes: []string{"profile"},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create insufficient badge resource failed: %v", err)
|
||||
}
|
||||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||||
GroupCode: "revoke_insufficient_pack",
|
||||
Name: "Revoke Insufficient Pack",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Items: []resourcedomain.ResourceGroupItemInput{
|
||||
{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1},
|
||||
{ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2},
|
||||
},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create insufficient resource group failed: %v", err)
|
||||
}
|
||||
grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
|
||||
CommandID: "cmd-grant-revoke-insufficient",
|
||||
TargetUserID: 42102,
|
||||
GroupID: group.GroupID,
|
||||
Reason: "test revoke insufficient",
|
||||
OperatorUserID: 90001,
|
||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GrantResourceGroup insufficient setup failed: %v", err)
|
||||
}
|
||||
entitlementID := entitlementIDFromGrant(t, grant)
|
||||
repository.SetBalance(42102, 100)
|
||||
|
||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-insufficient",
|
||||
GrantID: grant.GrantID,
|
||||
Reason: "test insufficient",
|
||||
OperatorUserID: 90002,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.InsufficientBalance) {
|
||||
t.Fatalf("revoke should fail with insufficient balance, got %v", err)
|
||||
}
|
||||
if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusDone); got != 1 {
|
||||
t.Fatalf("failed revoke must keep grant succeeded, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = 'active'", entitlementID); got != 1 {
|
||||
t.Fatalf("failed revoke must keep entitlement active, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 1 {
|
||||
t.Fatalf("failed revoke must keep equipment row, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 0 {
|
||||
t.Fatalf("failed revoke must not write reverse transaction, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeResourceGrantRejectsUnsupportedRecords 固定撤销范围:只允许成功的资源组发放,单资源、异常状态和不存在记录都不能误处理。
|
||||
func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||
ResourceCode: "coin_single_revoke_reject",
|
||||
ResourceType: resourcedomain.TypeCoin,
|
||||
Name: "Coin Single Revoke Reject",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategyWalletCredit,
|
||||
WalletAssetType: ledger.AssetCoin,
|
||||
WalletAssetAmount: 100,
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create single coin resource failed: %v", err)
|
||||
}
|
||||
singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
|
||||
CommandID: "cmd-single-revoke-reject",
|
||||
TargetUserID: 42103,
|
||||
ResourceID: coinResource.ResourceID,
|
||||
Quantity: 1,
|
||||
Reason: "single reject",
|
||||
OperatorUserID: 90001,
|
||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("single GrantResource setup failed: %v", err)
|
||||
}
|
||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-single-reject",
|
||||
GrantID: singleGrant.GrantID,
|
||||
Reason: "single reject",
|
||||
OperatorUserID: 90002,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("single resource revoke should be invalid, got %v", err)
|
||||
}
|
||||
|
||||
group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
|
||||
GroupCode: "failed_revoke_reject_pack",
|
||||
Name: "Failed Revoke Reject Pack",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Items: []resourcedomain.ResourceGroupItemInput{{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 10}},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed-status resource group failed: %v", err)
|
||||
}
|
||||
groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
|
||||
CommandID: "cmd-failed-revoke-reject",
|
||||
TargetUserID: 42104,
|
||||
GroupID: group.GroupID,
|
||||
Reason: "failed reject",
|
||||
OperatorUserID: 90001,
|
||||
GrantSource: resourcedomain.GrantSourceAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("group GrantResourceGroup setup failed: %v", err)
|
||||
}
|
||||
repository.SetResourceGrantStatus(groupGrant.GrantID, "failed")
|
||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-failed-reject",
|
||||
GrantID: groupGrant.GrantID,
|
||||
Reason: "failed reject",
|
||||
OperatorUserID: 90002,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("failed grant revoke should conflict, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
RequestID: "cmd-revoke-missing",
|
||||
GrantID: "rgr_missing_revoke",
|
||||
Reason: "missing reject",
|
||||
OperatorUserID: 90002,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.NotFound) {
|
||||
t.Fatalf("missing grant revoke should be not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。
|
||||
func TestGrantMicSeatAnimationAutoEquips(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -5584,6 +5832,17 @@ func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
|
||||
return -1
|
||||
}
|
||||
|
||||
func entitlementIDFromGrant(t *testing.T, grant resourcedomain.ResourceGrant) string {
|
||||
t.Helper()
|
||||
for _, item := range grant.Items {
|
||||
if item.ResultType == resourcedomain.ResultEntitlement && item.EntitlementID != "" {
|
||||
return item.EntitlementID
|
||||
}
|
||||
}
|
||||
t.Fatalf("grant has no entitlement item: %+v", grant)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -53,6 +53,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureResourceGrantRevokeSchema(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
bizTypeResourceGrant = "resource_grant"
|
||||
bizTypeResourceGrantRevoke = "resource_grant_revoke"
|
||||
bizTypeResourceShopPurchase = "resource_shop_purchase"
|
||||
resourceOutboxAsset = "RESOURCE"
|
||||
)
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -136,6 +137,116 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
|
||||
return r.GetResourceGrant(ctx, grantID)
|
||||
}
|
||||
|
||||
// RevokeResourceGrant 撤销一次成功的资源组发放;钱包扣回、权益失效、装备清理和发放状态更新必须同事务提交。
|
||||
func (r *Repository) RevokeResourceGrant(ctx context.Context, command resourcedomain.RevokeResourceGrantCommand) (resourcedomain.ResourceGrant, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
command = normalizeRevokeResourceGrantCommand(command)
|
||||
if err := validateRevokeResourceGrantCommand(command); err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
grant, err := r.lockResourceGrantTx(ctx, tx, command.GrantID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.NotFound, "resource grant not found")
|
||||
}
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
if grant.Status == resourcedomain.GrantStatusRevoked {
|
||||
grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grant.GrantID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
if grant.GrantSubjectType != resourcedomain.GrantSubjectGroup {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "only resource group grant can be revoked")
|
||||
}
|
||||
if grant.Status != resourcedomain.GrantStatusDone {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Conflict, "resource grant is not succeeded")
|
||||
}
|
||||
|
||||
grant.Items, err = r.listResourceGrantItemsTx(ctx, tx, grant.GrantID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
outboxEvents := make([]walletOutboxEvent, 0, len(grant.Items)+1)
|
||||
for _, item := range grant.Items {
|
||||
switch item.ResultType {
|
||||
case resourcedomain.ResultWalletCredit:
|
||||
event, err := r.revokeGrantedWalletCredit(ctx, tx, grant, item, command, nowMs)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
outboxEvents = append(outboxEvents, event)
|
||||
case resourcedomain.ResultEntitlement:
|
||||
event, err := r.revokeGrantedEntitlement(ctx, tx, grant, item, command, nowMs)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
outboxEvents = append(outboxEvents, event)
|
||||
default:
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.LedgerConflict, "resource grant item result type is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE resource_grants
|
||||
SET status = ?, revoked_at_ms = ?, revoked_by_user_id = ?, revoke_reason = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND grant_id = ? AND status = ?`,
|
||||
resourcedomain.GrantStatusRevoked, nowMs, command.OperatorUserID, command.Reason, nowMs,
|
||||
command.AppCode, grant.GrantID, resourcedomain.GrantStatusDone,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
if affected != 1 {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.LedgerConflict, "resource grant revoke state conflict")
|
||||
}
|
||||
|
||||
revokeCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, 0)
|
||||
groupID, _ := strconv.ParseInt(strings.TrimSpace(grant.GrantSubjectID), 10, 64)
|
||||
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGrantRevoked", revokeCommandID, grant.TargetUserID, groupID, map[string]any{
|
||||
"action": "revoke",
|
||||
"app_code": command.AppCode,
|
||||
"grant_id": grant.GrantID,
|
||||
"target_user_id": grant.TargetUserID,
|
||||
"grant_subject_type": grant.GrantSubjectType,
|
||||
"grant_subject_id": grant.GrantSubjectID,
|
||||
"revoked_by_user_id": command.OperatorUserID,
|
||||
"revoke_reason": command.Reason,
|
||||
"revoked_at_ms": nowMs,
|
||||
"event_command": revokeCommandID,
|
||||
}, nowMs))
|
||||
if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
|
||||
revoked, err := r.getResourceGrantTx(ctx, tx, grant.GrantID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resourcedomain.ResourceGrant{}, err
|
||||
}
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
@ -352,6 +463,128 @@ func (r *Repository) applyWalletAssetGrantItem(ctx context.Context, tx *sql.Tx,
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func (r *Repository) revokeGrantedWalletCredit(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
|
||||
if strings.TrimSpace(item.WalletTransactionID) == "" {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant wallet transaction is missing")
|
||||
}
|
||||
// 反扣金额以原入账分录为准,避免资源组或资源定义后续变更导致撤销扣回的内容漂移。
|
||||
original, err := r.lockOriginalGrantCreditEntry(ctx, tx, item.WalletTransactionID, grant.TargetUserID)
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if original.AvailableDelta <= 0 || original.FrozenDelta != 0 {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant wallet entry is not reversible")
|
||||
}
|
||||
account, err := r.lockAccount(ctx, tx, grant.TargetUserID, original.AssetType, false, nowMs)
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if account.AvailableAmount < original.AvailableDelta {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.InsufficientBalance, "insufficient balance to revoke resource grant")
|
||||
}
|
||||
|
||||
walletCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
||||
transactionID := transactionID(command.AppCode, walletCommandID)
|
||||
metadata := map[string]any{
|
||||
"app_code": command.AppCode,
|
||||
"grant_id": grant.GrantID,
|
||||
"grant_item_id": item.GrantItemID,
|
||||
"target_user_id": grant.TargetUserID,
|
||||
"asset_type": original.AssetType,
|
||||
"wallet_amount": original.AvailableDelta,
|
||||
"original_transaction_id": item.WalletTransactionID,
|
||||
"revoked_by_user_id": command.OperatorUserID,
|
||||
"revoke_reason": command.Reason,
|
||||
"operator_reason": "resource grant revoke",
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, walletCommandID, bizTypeResourceGrantRevoke, resourceGrantWalletRevokeHash(grant.GrantID, item.GrantItemID, original.AssetType, original.AvailableDelta), grant.GrantID, metadata, nowMs); err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, account, -original.AvailableDelta, 0, nowMs); err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
availableAfter := account.AvailableAmount - original.AvailableDelta
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: grant.TargetUserID,
|
||||
AssetType: original.AssetType,
|
||||
AvailableDelta: -original.AvailableDelta,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: availableAfter,
|
||||
FrozenAfter: account.FrozenAmount,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
return balanceChangedEvent(transactionID, walletCommandID, grant.TargetUserID, original.AssetType, -original.AvailableDelta, 0, availableAfter, account.FrozenAmount, account.Version+1, metadata, nowMs), nil
|
||||
}
|
||||
|
||||
func (r *Repository) revokeGrantedEntitlement(ctx context.Context, tx *sql.Tx, grant resourcedomain.ResourceGrant, item resourcedomain.ResourceGrantItem, command resourcedomain.RevokeResourceGrantCommand, nowMs int64) (walletOutboxEvent, error) {
|
||||
if strings.TrimSpace(item.EntitlementID) == "" {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is missing")
|
||||
}
|
||||
// 撤销不折算有效期,直接让原发放产生的权益不可用;装备表同步删除,避免客户端继续展示已穿戴状态。
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_resource_entitlements
|
||||
SET status = ?, remaining_quantity = 0, updated_at_ms = ?
|
||||
WHERE app_code = ? AND entitlement_id = ? AND user_id = ?`,
|
||||
resourcedomain.GrantStatusRevoked, nowMs, command.AppCode, item.EntitlementID, grant.TargetUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
if affected != 1 {
|
||||
return walletOutboxEvent{}, xerr.New(xerr.LedgerConflict, "resource grant entitlement is not reversible")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM user_resource_equipment
|
||||
WHERE app_code = ? AND user_id = ? AND entitlement_id = ?`,
|
||||
command.AppCode, grant.TargetUserID, item.EntitlementID,
|
||||
); err != nil {
|
||||
return walletOutboxEvent{}, err
|
||||
}
|
||||
|
||||
eventCommandID := resourceGrantRevokeCommandID(command, grant.GrantID, item.GrantItemID)
|
||||
return resourceOutboxEvent("UserResourceChanged", eventCommandID, grant.TargetUserID, item.ResourceID, map[string]any{
|
||||
"action": "revoke",
|
||||
"app_code": command.AppCode,
|
||||
"user_id": grant.TargetUserID,
|
||||
"grant_id": grant.GrantID,
|
||||
"grant_item_id": item.GrantItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"entitlement_id": item.EntitlementID,
|
||||
"revoked_by_user_id": command.OperatorUserID,
|
||||
"revoke_reason": command.Reason,
|
||||
"updated_at_ms": nowMs,
|
||||
"event_command": eventCommandID,
|
||||
}, nowMs), nil
|
||||
}
|
||||
|
||||
func (r *Repository) lockOriginalGrantCreditEntry(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (walletEntry, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT asset_type, available_delta, frozen_delta
|
||||
FROM wallet_entries
|
||||
WHERE app_code = ? AND transaction_id = ? AND user_id = ?
|
||||
ORDER BY entry_id DESC LIMIT 1
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx), transactionID, userID,
|
||||
)
|
||||
var entry walletEntry
|
||||
entry.TransactionID = transactionID
|
||||
entry.UserID = userID
|
||||
if err := row.Scan(&entry.AssetType, &entry.AvailableDelta, &entry.FrozenDelta); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return walletEntry{}, xerr.New(xerr.LedgerConflict, "resource grant wallet entry not found")
|
||||
}
|
||||
return walletEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO resource_grant_items (
|
||||
@ -420,6 +653,11 @@ func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID
|
||||
return grant, err
|
||||
}
|
||||
|
||||
func (r *Repository) lockResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID string) (resourcedomain.ResourceGrant, error) {
|
||||
row := tx.QueryRowContext(ctx, resourceGrantSelectSQL()+`WHERE app_code = ? AND grant_id = ? FOR UPDATE`, appcode.FromContext(ctx), grantID)
|
||||
return scanResourceGrant(row)
|
||||
}
|
||||
|
||||
func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
|
||||
return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID)
|
||||
}
|
||||
@ -467,6 +705,9 @@ func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error)
|
||||
&grant.Status,
|
||||
&grant.Reason,
|
||||
&grant.OperatorUserID,
|
||||
&grant.RevokedAtMS,
|
||||
&grant.RevokedByUserID,
|
||||
&grant.RevokeReason,
|
||||
&grant.CreatedAtMS,
|
||||
&grant.UpdatedAtMS,
|
||||
)
|
||||
@ -476,7 +717,8 @@ func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error)
|
||||
func resourceGrantSelectSQL() string {
|
||||
return `
|
||||
SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
|
||||
grant_subject_id, status, reason, operator_user_id, created_at_ms, updated_at_ms
|
||||
grant_subject_id, status, reason, operator_user_id,
|
||||
revoked_at_ms, revoked_by_user_id, revoke_reason, created_at_ms, updated_at_ms
|
||||
FROM resource_grants
|
||||
`
|
||||
}
|
||||
@ -524,6 +766,20 @@ func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroup
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRevokeResourceGrantCommand(command resourcedomain.RevokeResourceGrantCommand) resourcedomain.RevokeResourceGrantCommand {
|
||||
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||
command.GrantID = strings.TrimSpace(command.GrantID)
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
return command
|
||||
}
|
||||
|
||||
func validateRevokeResourceGrantCommand(command resourcedomain.RevokeResourceGrantCommand) error {
|
||||
if command.GrantID == "" || command.OperatorUserID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource grant revoke command is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {
|
||||
|
||||
if grantSource == resourcedomain.GrantSourceGameRobotInit {
|
||||
@ -579,6 +835,21 @@ func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType str
|
||||
return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
|
||||
}
|
||||
|
||||
func resourceGrantWalletRevokeHash(grantID string, grantItemID int64, assetType string, amount int64) string {
|
||||
return stableHash(fmt.Sprintf("resource_grant_wallet_revoke|%s|%d|%s|%d", grantID, grantItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
|
||||
}
|
||||
|
||||
func resourceGrantRevokeCommandID(command resourcedomain.RevokeResourceGrantCommand, grantID string, grantItemID int64) string {
|
||||
base := strings.TrimSpace(command.RequestID)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(grantID)
|
||||
}
|
||||
if grantItemID > 0 {
|
||||
return fmt.Sprintf("resource_grant_revoke:%s:item:%d", base, grantItemID)
|
||||
}
|
||||
return fmt.Sprintf("resource_grant_revoke:%s", base)
|
||||
}
|
||||
|
||||
func resourceGrantID(appCode string, commandID string) string {
|
||||
return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
|
||||
}
|
||||
|
||||
@ -25,6 +25,25 @@ func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureResourceGrantRevokeSchema(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE resource_grants
|
||||
ADD COLUMN revoked_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '撤销时间,UTC epoch ms' AFTER operator_user_id`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE resource_grants
|
||||
ADD COLUMN revoked_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '撤销操作人用户 ID' AFTER revoked_at_ms`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
ALTER TABLE resource_grants
|
||||
ADD COLUMN revoke_reason VARCHAR(256) NOT NULL DEFAULT '' COMMENT '撤销原因' AFTER revoked_by_user_id`); err != nil && !isDuplicateColumnError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateKeyNameError(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061
|
||||
|
||||
@ -89,6 +89,20 @@ func (r *Repository) ExpireEntitlement(entitlementID string, expiresAtMS int64)
|
||||
}
|
||||
}
|
||||
|
||||
// SetResourceGrantStatus lets service tests model legacy or abnormal grant rows without bypassing production grant creation.
|
||||
func (r *Repository) SetResourceGrantStatus(grantID string, status string) {
|
||||
r.t.Helper()
|
||||
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
UPDATE resource_grants
|
||||
SET status = ?, updated_at_ms = ?
|
||||
WHERE grant_id = ?`,
|
||||
status, time.Now().UnixMilli(), grantID,
|
||||
); err != nil {
|
||||
r.t.Fatalf("set resource grant status failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order.
|
||||
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
|
||||
r.t.Helper()
|
||||
|
||||
@ -250,6 +250,20 @@ func (s *Server) GrantResourceGroup(ctx context.Context, req *walletv1.GrantReso
|
||||
return &walletv1.ResourceGrantResponse{Grant: resourceGrantToProto(grant)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) RevokeResourceGrant(ctx context.Context, req *walletv1.RevokeResourceGrantRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
grant, err := s.svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
RequestID: req.GetRequestId(),
|
||||
GrantID: req.GetGrantId(),
|
||||
Reason: req.GetReason(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.ResourceGrantResponse{Grant: resourceGrantToProto(grant)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
|
||||
items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
|
||||
AppCode: req.GetAppCode(),
|
||||
|
||||
@ -221,5 +221,8 @@ func resourceGrantToProto(grant resourcedomain.ResourceGrant) *walletv1.Resource
|
||||
Items: items,
|
||||
CreatedAtMs: grant.CreatedAtMS,
|
||||
UpdatedAtMs: grant.UpdatedAtMS,
|
||||
RevokedAtMs: grant.RevokedAtMS,
|
||||
RevokedByUserId: grant.RevokedByUserID,
|
||||
RevokeReason: grant.RevokeReason,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user