撤销资源组

This commit is contained in:
zhx 2026-06-24 19:49:01 +08:00
parent 9c547faf0e
commit 6fd0f03895
23 changed files with 4386 additions and 3541 deletions

File diff suppressed because it is too large Load Diff

View File

@ -494,6 +494,9 @@ message ResourceGrant {
repeated ResourceGrantItem items = 11; repeated ResourceGrantItem items = 11;
int64 created_at_ms = 12; int64 created_at_ms = 12;
int64 updated_at_ms = 13; int64 updated_at_ms = 13;
int64 revoked_at_ms = 14;
int64 revoked_by_user_id = 15;
string revoke_reason = 16;
} }
message ResourceGroupItemInput { message ResourceGroupItemInput {
@ -793,6 +796,14 @@ message GrantResourceGroupRequest {
string grant_source = 7; 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 { message ResourceGrantResponse {
ResourceGrant grant = 1; ResourceGrant grant = 1;
} }
@ -1989,6 +2000,7 @@ service WalletService {
rpc UpsertGiftTypeConfig(UpsertGiftTypeConfigRequest) returns (GiftTypeConfigResponse); rpc UpsertGiftTypeConfig(UpsertGiftTypeConfigRequest) returns (GiftTypeConfigResponse);
rpc GrantResource(GrantResourceRequest) returns (ResourceGrantResponse); rpc GrantResource(GrantResourceRequest) returns (ResourceGrantResponse);
rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse); rpc GrantResourceGroup(GrantResourceGroupRequest) returns (ResourceGrantResponse);
rpc RevokeResourceGrant(RevokeResourceGrantRequest) returns (ResourceGrantResponse);
rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse); rpc ListUserResources(ListUserResourcesRequest) returns (ListUserResourcesResponse);
rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse); rpc EquipUserResource(EquipUserResourceRequest) returns (EquipUserResourceResponse);
rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse); rpc UnequipUserResource(UnequipUserResourceRequest) returns (UnequipUserResourceResponse);

View File

@ -232,6 +232,7 @@ const (
WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig" WalletService_UpsertGiftTypeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertGiftTypeConfig"
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource" WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup" 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_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource" WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_UnequipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UnequipUserResource" 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) UpsertGiftTypeConfig(ctx context.Context, in *UpsertGiftTypeConfigRequest, opts ...grpc.CallOption) (*GiftTypeConfigResponse, error)
GrantResource(ctx context.Context, in *GrantResourceRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error) GrantResource(ctx context.Context, in *GrantResourceRequest, opts ...grpc.CallOption) (*ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, in *GrantResourceGroupRequest, 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) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error)
EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error) EquipUserResource(ctx context.Context, in *EquipUserResourceRequest, opts ...grpc.CallOption) (*EquipUserResourceResponse, error)
UnequipUserResource(ctx context.Context, in *UnequipUserResourceRequest, opts ...grpc.CallOption) (*UnequipUserResourceResponse, 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 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) { func (c *walletServiceClient) ListUserResources(ctx context.Context, in *ListUserResourcesRequest, opts ...grpc.CallOption) (*ListUserResourcesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListUserResourcesResponse) out := new(ListUserResourcesResponse)
@ -1276,6 +1288,7 @@ type WalletServiceServer interface {
UpsertGiftTypeConfig(context.Context, *UpsertGiftTypeConfigRequest) (*GiftTypeConfigResponse, error) UpsertGiftTypeConfig(context.Context, *UpsertGiftTypeConfigRequest) (*GiftTypeConfigResponse, error)
GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error) GrantResource(context.Context, *GrantResourceRequest) (*ResourceGrantResponse, error)
GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error)
RevokeResourceGrant(context.Context, *RevokeResourceGrantRequest) (*ResourceGrantResponse, error)
ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error)
EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error) EquipUserResource(context.Context, *EquipUserResourceRequest) (*EquipUserResourceResponse, error)
UnequipUserResource(context.Context, *UnequipUserResourceRequest) (*UnequipUserResourceResponse, 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) { func (UnimplementedWalletServiceServer) GrantResourceGroup(context.Context, *GrantResourceGroupRequest) (*ResourceGrantResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GrantResourceGroup not implemented") 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) { func (UnimplementedWalletServiceServer) ListUserResources(context.Context, *ListUserResourcesRequest) (*ListUserResourcesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserResources not implemented") 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) 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) { func _WalletService_ListUserResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListUserResourcesRequest) in := new(ListUserResourcesRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -3277,6 +3311,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GrantResourceGroup", MethodName: "GrantResourceGroup",
Handler: _WalletService_GrantResourceGroup_Handler, Handler: _WalletService_GrantResourceGroup_Handler,
}, },
{
MethodName: "RevokeResourceGrant",
Handler: _WalletService_RevokeResourceGrant_Handler,
},
{ {
MethodName: "ListUserResources", MethodName: "ListUserResources",
Handler: _WalletService_ListUserResources_Handler, Handler: _WalletService_ListUserResources_Handler,

View File

@ -288,6 +288,7 @@
| `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 | | `resource-group:update` | `button` | 编辑资源组、替换组成员、启用、禁用 |
| `resource-grant:view` | `menu` | 资源赠送记录 | | `resource-grant:view` | `menu` | 资源赠送记录 |
| `resource-grant:create` | `button` | 给用户赠送资源或资源组 | | `resource-grant:create` | `button` | 给用户赠送资源或资源组 |
| `resource-grant:revoke` | `button` | 撤销成功的资源组赠送 |
| `gift:view` | `menu` | 礼物列表 | | `gift:view` | `menu` | 礼物列表 |
| `gift:create` | `button` | 从资源库选择资源新增礼物 | | `gift:create` | `button` | 从资源库选择资源新增礼物 |
| `gift:update` | `button` | 编辑礼物、资源绑定和价格 | | `gift:update` | `button` | 编辑礼物、资源绑定和价格 |

View File

@ -29,6 +29,7 @@ type Client interface {
UpsertGiftTypeConfig(ctx context.Context, req *walletv1.UpsertGiftTypeConfigRequest) (*walletv1.GiftTypeConfigResponse, error) UpsertGiftTypeConfig(ctx context.Context, req *walletv1.UpsertGiftTypeConfigRequest) (*walletv1.GiftTypeConfigResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*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) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error) ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, 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) 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) { func (c *GRPCClient) EquipUserResource(ctx context.Context, req *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
return c.client.EquipUserResource(ctx, req) return c.client.EquipUserResource(ctx, req)
} }

View File

@ -139,6 +139,9 @@ type grantDTO struct {
Reason string `json:"reason"` Reason string `json:"reason"`
OperatorUserID int64 `json:"operatorUserId"` OperatorUserID int64 `json:"operatorUserId"`
Operator *operatorDTO `json:"operator,omitempty"` Operator *operatorDTO `json:"operator,omitempty"`
RevokedAtMS int64 `json:"revokedAtMs"`
RevokedByUserID int64 `json:"revokedByUserId,string"`
RevokeReason string `json:"revokeReason"`
Items []grantItemDTO `json:"items"` Items []grantItemDTO `json:"items"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"` UpdatedAtMS int64 `json:"updatedAtMs"`
@ -436,6 +439,9 @@ func grantFromProto(grant *walletv1.ResourceGrant) grantDTO {
Status: grant.GetStatus(), Status: grant.GetStatus(),
Reason: grant.GetReason(), Reason: grant.GetReason(),
OperatorUserID: grant.GetOperatorUserId(), OperatorUserID: grant.GetOperatorUserId(),
RevokedAtMS: grant.GetRevokedAtMs(),
RevokedByUserID: grant.GetRevokedByUserId(),
RevokeReason: grant.GetRevokeReason(),
Items: items, Items: items,
CreatedAtMS: grant.GetCreatedAtMs(), CreatedAtMS: grant.GetCreatedAtMs(),
UpdatedAtMS: grant.GetUpdatedAtMs(), UpdatedAtMS: grant.GetUpdatedAtMs(),

View File

@ -601,6 +601,28 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
response.Created(c, grant) 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) { func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword")) keyword := strings.TrimSpace(firstQuery(c, "user_id", "userId", "keyword"))
if keyword == "" { if keyword == "" {

View File

@ -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/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/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/target", middleware.RequirePermission("resource-grant:create"), h.LookupResourceGrantTarget)
protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants) protected.GET("/admin/resource-grants", middleware.RequirePermission("resource-grant:view"), h.ListResourceGrants)

View File

@ -57,6 +57,7 @@ var defaultPermissions = []model.Permission{
{Name: "资源组更新", Code: "resource-group:update", Kind: "button"}, {Name: "资源组更新", Code: "resource-group:update", Kind: "button"},
{Name: "资源赠送查看", Code: "resource-grant:view", Kind: "menu"}, {Name: "资源赠送查看", Code: "resource-grant:view", Kind: "menu"},
{Name: "资源赠送创建", Code: "resource-grant:create", Kind: "button"}, {Name: "资源赠送创建", Code: "resource-grant:create", Kind: "button"},
{Name: "资源赠送撤销", Code: "resource-grant:revoke", Kind: "button"},
{Name: "礼物查看", Code: "gift:view", Kind: "menu"}, {Name: "礼物查看", Code: "gift:view", Kind: "menu"},
{Name: "礼物创建", Code: "gift:create", Kind: "button"}, {Name: "礼物创建", Code: "gift:create", Kind: "button"},
{Name: "礼物更新", Code: "gift:update", Kind: "button"}, {Name: "礼物更新", Code: "gift:update", Kind: "button"},
@ -534,7 +535,7 @@ func defaultRolePermissionCodes(code string) []string {
"resource:view", "resource:create", "resource:update", "resource:view", "resource:create", "resource:update",
"resource-shop:view", "resource-shop:update", "resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group: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", "gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create", "emoji-pack:view", "emoji-pack:create",
"country:view", "country:create", "country:update", "country:status", "country:view", "country:create", "country:update", "country:status",
@ -637,7 +638,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"resource:view", "resource:create", "resource:update", "resource:view", "resource:create", "resource:update",
"resource-shop:view", "resource-shop:update", "resource-shop:view", "resource-shop:update",
"resource-group:view", "resource-group:create", "resource-group: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", "gift:view", "gift:create", "gift:update", "gift:status", "gift:delete",
"emoji-pack:view", "emoji-pack:create", "emoji-pack:view", "emoji-pack:create",
"upload:create", "upload:create",

View File

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

View File

@ -1140,6 +1140,9 @@ CREATE TABLE IF NOT EXISTS resource_grants (
group_snapshot_json JSON NULL COMMENT '资源分组快照 JSON', group_snapshot_json JSON NULL COMMENT '资源分组快照 JSON',
reason VARCHAR(256) NOT NULL COMMENT '原因', reason VARCHAR(256) NOT NULL COMMENT '原因',
operator_user_id BIGINT NOT NULL COMMENT '操作人用户 ID', 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', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_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), PRIMARY KEY (app_code, grant_id),

View File

@ -32,6 +32,7 @@ const (
GrantSourceResourceShop = "resource_shop" GrantSourceResourceShop = "resource_shop"
GrantSourceGameRobotInit = "game_robot_init" GrantSourceGameRobotInit = "game_robot_init"
GrantStatusDone = "succeeded" GrantStatusDone = "succeeded"
GrantStatusRevoked = "revoked"
ResultWalletCredit = "wallet_credit" ResultWalletCredit = "wallet_credit"
ResultEntitlement = "entitlement" ResultEntitlement = "entitlement"

View File

@ -24,6 +24,9 @@ type ResourceGrant struct {
Status string Status string
Reason string Reason string
OperatorUserID int64 OperatorUserID int64
RevokedAtMS int64
RevokedByUserID int64
RevokeReason string
Items []ResourceGrantItem Items []ResourceGrantItem
CreatedAtMS int64 CreatedAtMS int64
UpdatedAtMS int64 UpdatedAtMS int64
@ -51,6 +54,14 @@ type GrantResourceGroupCommand struct {
GrantSource string GrantSource string
} }
type RevokeResourceGrantCommand struct {
AppCode string
RequestID string
GrantID string
Reason string
OperatorUserID int64
}
type ListResourceGrantsQuery struct { type ListResourceGrantsQuery struct {
AppCode string AppCode string
TargetUserID int64 TargetUserID int64

View File

@ -165,6 +165,7 @@ type ResourceCatalogStore interface {
type ResourceGrantStore interface { type ResourceGrantStore interface {
GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error) GrantResource(ctx context.Context, command resourcedomain.GrantResourceCommand) (resourcedomain.ResourceGrant, error)
GrantResourceGroup(ctx context.Context, command resourcedomain.GrantResourceGroupCommand) (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) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
} }

View File

@ -190,6 +190,15 @@ func (s *Service) GrantResourceGroup(ctx context.Context, command resourcedomain
return s.repository.GrantResourceGroup(ctx, command) 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) { func (s *Service) ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error) {
if s.repository == nil { if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")

View File

@ -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 验证麦位声波资源在发放事务内默认佩戴。 // TestGrantMicSeatAnimationAutoEquips 验证麦位声波资源在发放事务内默认佩戴。
func TestGrantMicSeatAnimationAutoEquips(t *testing.T) { func TestGrantMicSeatAnimationAutoEquips(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -5584,6 +5832,17 @@ func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
return -1 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) { func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) {
t.Helper() t.Helper()

View File

@ -53,6 +53,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
_ = db.Close() _ = db.Close()
return nil, err return nil, err
} }
if err := ensureResourceGrantRevokeSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil return &Repository{db: db}, nil
} }

View File

@ -7,6 +7,7 @@ import (
const ( const (
bizTypeResourceGrant = "resource_grant" bizTypeResourceGrant = "resource_grant"
bizTypeResourceGrantRevoke = "resource_grant_revoke"
bizTypeResourceShopPurchase = "resource_shop_purchase" bizTypeResourceShopPurchase = "resource_shop_purchase"
resourceOutboxAsset = "RESOURCE" resourceOutboxAsset = "RESOURCE"
) )

View File

@ -9,6 +9,7 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -136,6 +137,116 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
return r.GetResourceGrant(ctx, grantID) 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) { func (r *Repository) ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") 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 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) { func (r *Repository) insertResourceGrantItem(ctx context.Context, tx *sql.Tx, item resourcedomain.ResourceGrantItem) (resourcedomain.ResourceGrantItem, error) {
result, err := tx.ExecContext(ctx, ` result, err := tx.ExecContext(ctx, `
INSERT INTO resource_grant_items ( INSERT INTO resource_grant_items (
@ -420,6 +653,11 @@ func (r *Repository) getResourceGrantTx(ctx context.Context, tx *sql.Tx, grantID
return grant, err 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) { func (r *Repository) listResourceGrantItems(ctx context.Context, grantID string) ([]resourcedomain.ResourceGrantItem, error) {
return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID) return r.listResourceGrantItemsWithQuery(ctx, r.db, grantID)
} }
@ -467,6 +705,9 @@ func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error)
&grant.Status, &grant.Status,
&grant.Reason, &grant.Reason,
&grant.OperatorUserID, &grant.OperatorUserID,
&grant.RevokedAtMS,
&grant.RevokedByUserID,
&grant.RevokeReason,
&grant.CreatedAtMS, &grant.CreatedAtMS,
&grant.UpdatedAtMS, &grant.UpdatedAtMS,
) )
@ -476,7 +717,8 @@ func scanResourceGrant(scanner scanTarget) (resourcedomain.ResourceGrant, error)
func resourceGrantSelectSQL() string { func resourceGrantSelectSQL() string {
return ` return `
SELECT app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type, 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 FROM resource_grants
` `
} }
@ -524,6 +766,20 @@ func validateGrantResourceGroupCommand(command resourcedomain.GrantResourceGroup
return nil 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 { func validateGrantableResourceForSource(resource resourcedomain.Resource, grantSource string) error {
if grantSource == resourcedomain.GrantSourceGameRobotInit { 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)) 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 { func resourceGrantID(appCode string, commandID string) string {
return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) return "rgr_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
} }

View File

@ -25,6 +25,25 @@ func ensureResourceShopPurchaseOrderIndexes(ctx context.Context, db *sql.DB) err
return nil 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 { func isDuplicateKeyNameError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061 return errors.As(err, &mysqlErr) && mysqlErr.Number == 1061

View File

@ -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. // HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order.
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string { func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
r.t.Helper() r.t.Helper()

View File

@ -250,6 +250,20 @@ func (s *Server) GrantResourceGroup(ctx context.Context, req *walletv1.GrantReso
return &walletv1.ResourceGrantResponse{Grant: resourceGrantToProto(grant)}, nil 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) { func (s *Server) ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{ items, err := s.svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
AppCode: req.GetAppCode(), AppCode: req.GetAppCode(),

View File

@ -221,5 +221,8 @@ func resourceGrantToProto(grant resourcedomain.ResourceGrant) *walletv1.Resource
Items: items, Items: items,
CreatedAtMs: grant.CreatedAtMS, CreatedAtMs: grant.CreatedAtMS,
UpdatedAtMs: grant.UpdatedAtMS, UpdatedAtMs: grant.UpdatedAtMS,
RevokedAtMs: grant.RevokedAtMS,
RevokedByUserId: grant.RevokedByUserID,
RevokeReason: grant.RevokeReason,
} }
} }