撤销资源组

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

View File

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

View File

@ -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` | 编辑礼物、资源绑定和价格 |

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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 验证麦位声波资源在发放事务内默认佩戴。
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()

View File

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

View File

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

View File

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

View File

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

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.
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
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
}
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(),

View File

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