授权

在上一章中,我们改进了 DevShow 的导航与样式。现在让我们为用户添加编辑和删除自己文章与评论的功能。目前,如果我们添加这些功能,任何已登录用户都能修改他人的内容。我们需要添加授权检查来防止这种情况。

概述

为了正确处理权限,我们将使用 AdonisJS 的 Bouncer 包 。Bouncer 允许你将授权逻辑组织成策略(policy)(每个方法代表一项权限检查的class)。例如,PostPolicy 可以有一个 edit 方法,用于检查某用户能否编辑特定文章。

与其将权限检查分散在控制器的各处,不如在策略中定义一次规则,然后在任何地方复用。在本章中,我们将安装 Bouncer、为文章和评论创建策略,并实现带有正确授权的编辑与删除功能。

安装 Bouncer

让我们通过以下命令安装并配置 Bouncer 包。

node ace add @adonisjs/bouncer

运行该命令会先安装包,然后执行以下操作。

  • 创建 app/abilities/main.ts 文件,你可以在其中定义授权能力(目前我们用不到这个文件,无需担心)
  • 注册一个中间件,为每个 HTTP 请求初始化 Bouncer
  • 使 bouncer 对象在 HttpContext 上可用,方便在控制器中使用

一切就绪!现在让我们创建第一个策略。

创建 PostPolicy

策略是一些 class,其中每个方法代表一项权限检查。让我们为文章创建一个策略。

node ace make:policy post

打开生成的文件,添加对文章编辑与删除的权限检查。

app/policies/post_policy.ts
import type User from '#models/user'
import type Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'

export default class PostPolicy extends BasePolicy {
  /**
   * Only the post owner can edit their post
   */
  edit(user: User, post: Post) {
    return user.id === post.userId
  }

  /**
   * Only the post owner can delete their post
   */
  delete(user: User, post: Post) {
    return user.id === post.userId
  }
}

每个策略方法都以当前登录用户作为第一个参数,后面跟着被检查的资源(此处为 post)。如果用户被允许执行该操作,方法返回 true,否则返回 false。这里我们只是简单地检查用户的 ID 是否与文章的 userId 匹配。

你可能会注意到 editdelete 目前逻辑完全相同。即便如此,将它们分开可以带来灵活性。将来你或许会决定文章在 24 小时后不可编辑,或者管理员可以删除任意文章但不能编辑。拥有独立的方法让这类改动更容易。

创建 CommentPolicy

现在为评论创建一个策略。

node ace make:policy comment

添加删除的权限检查。

app/policies/comment_policy.ts
import type User from '#models/user'
import type Comment from '#models/comment'
import { BasePolicy } from '@adonisjs/bouncer'

export default class CommentPolicy extends BasePolicy {
  /**
   * Only the comment owner can delete their comment
   */
  delete(user: User, comment: Comment) {
    return user.id === comment.userId
  }
}

完美!现在让我们把这些策略用起来。

添加编辑功能

  1. Create the update validator

    我们将为更新文章添加一个验证器。由于我们已经有了一个用于创建文章的 validators/post.ts 文件,我们也会把更新验证器加进去。单个验证器文件可以导出多个验证器(这样能把相关的验证逻辑组织在一起)。

    打开你已有的文章验证器文件,添加更新验证器。

    app/validators/post.ts
    import vine from '@vinejs/vine'
    
    export const createPostValidator = vine.create({
      title: vine.string().minLength(3).maxLength(255),
      url: vine.string().url(),
      summary: vine.string().minLength(80).maxLength(500),
    })
    
    /**
     * Same validation rules as creating a post
     */
    export const updatePostValidator = vine.create(
      createPostValidator.schema.clone()
    )

    我们克隆了 createPostValidator 的 schema 以复用相同的验证规则。这种方式使我们的验证逻辑保持 DRY(Don't Repeat Yourself,不重复自己)。如果你之后需要修改某条规则,只需在一处更新即可。在许多应用中,你可能会希望创建与更新的规则不同,但在 DevShow 中,两者的要求是相同的。

  2. Add controller methods

    我们将添加两个控制器方法:edit 用于显示编辑表单,update 用于处理表单提交。这两个方法在执行任何操作之前,都会使用 Bouncer 检查当前用户是否有权修改该文章。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import PostTransformer from '#transformers/post_transformer'
    import { createPostValidator, updatePostValidator } from '#validators/post'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostsController {
      // ... existing methods (index, create, store, show)
    
      /**
       * Show the edit form
       */
      async edit({ bouncer, params, inertia }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // Check if the current user can edit this post
        await bouncer.with(PostPolicy).authorize('edit', post)
    
        return inertia.render('posts/edit', {
          post: PostTransformer.transform(post),
        })
      }
    
      /**
       * Update the post
       */
      async update({ bouncer, params, request, response, session }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // Check authorization again. Someone could send a PUT request directly
        await bouncer.with(PostPolicy).authorize('edit', post)
    
        // Validate and update the post
        const data = await request.validateUsing(updatePostValidator)
        await post.merge(data).save()
    
        session.flash('success', 'Post updated successfully')
        return response.redirect().toRoute('posts.show', { id: post.id })
      }
    }

    这里的关键在于 bouncer.with(PostPolicy).authorize('edit', post)。这一行:

    • 调用 PostPolicy 中的 edit 方法
    • 将文章传递给该策略方法
    • 如果策略返回 false,Bouncer 会自动抛出 403 Forbidden 错误
    • 如果策略返回 true,代码继续执行

    我们在两个方法中都进行了授权检查。即便 edit 已经检查了权限,有人仍可能绕过表单直接对 update 路由发送 PUT 请求。在执行敏感操作之前,务必再次验证权限。

    你还会注意到 update 方法中的 session.flash('success', 'Post updated successfully')Flash 消息 是存储在会话中的临时消息,在下一个请求中可用,随后会自动移除。这非常适合在表单提交后显示成功或错误消息。

    你无需添加任何代码来显示这些消息——入门套件的布局已经包含了一个会自动渲染 flash 消息的组件。当设置了 flash 消息时,它会在下一个请求中以页面顶部的通知形式出现。

  3. Update the Post transformer to include authorization

    既然我们已经了解如何在控制器中使用策略,现在让我们也在转换器(transformer)中使用它们,将授权标志发送给前端。

    这里有一个重要的考量:Bouncer 策略运行在后端环境中,因此无法在 React 代码中直接导入或使用。 你的 React 组件无法访问后端的授权逻辑。

    解决方案是在转换器内部预计算用户权限,并将它们作为标志发送给前端。转换器运行在后端,可以访问策略,并能在序列化数据中包含权限检查。

    为此,我们将使用一个转换器变体(transformer variant)。变体允许你为同一资源定义多种输出形态。例如,列表视图可能只需要最少的数据,而详情视图(包含权限)则需要详细数据。在 Transformers 文档 中可以了解更多关于变体的内容。

    让我们为 PostTransformer 添加一个 forDetailedView 变体:

    app/transformers/post_transformer.ts
    import { BaseTransformer } from '@adonisjs/core/transformers'
    import type Post from '#models/post'
    import UserTransformer from '#transformers/user_transformer'
    import CommentTransformer from '#transformers/comment_transformer'
    import { inject } from '@adonisjs/core'
    import { HttpContext } from '@adonisjs/core/http'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostTransformer extends BaseTransformer<Post> {
      toObject() {
        return {
          ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
          author: UserTransformer.transform(this.resource.user),
          comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
        }
      }
    
      /**
       * Include authorization data for the post detail view
       */
      @inject()
      async forDetailedView({ bouncer }: HttpContext) {
        return {
          ...this.toObject(),
          can: {
            edit: await bouncer.with(PostPolicy).allows('edit', this.resource),
            delete: await bouncer.with(PostPolicy).allows('delete', this.resource),
          },
        }
      }
    }

    注意我们使用的 bouncer.with(PostPolicy) 模式与控制器中相同,但此处用的是 .allows()(返回布尔值)而非 .authorize()(会抛出错误)。@inject() 装饰器使我们能够在转换器中访问 HTTP 上下文。

    当你的 React 组件收到这份数据时,它拥有了简单的布尔标志(post.can.editpost.can.delete),可以用于条件渲染——而无需了解任何授权逻辑本身。

    现在更新 show 方法以使用这个变体:

    app/controllers/posts_controller.ts
    async show({ inertia, params }: HttpContext) {
      const post = await Post.query()
        .where('id', params.id)
        .preload('user')
        .preload('comments', (query) => {
          query.preload('user').orderBy('createdAt', 'asc')
        })
        .firstOrFail()
    
      return inertia.render('posts/show', {
        post: PostTransformer.transform(post),
      })
      return inertia.render('posts/show', {
        post: PostTransformer.transform(post).useVariant('forDetailedView'),
      })
    }
  4. Register the routes

    现在让我们注册用于编辑文章的路由。打开你的路由文件。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts', [controllers.Posts, 'index'])
    router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
    router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
    router.get('/posts/:id', [controllers.Posts, 'show'])
    
    router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())

    我们需要两个路由(一个用于显示编辑表单,另一个用于处理表单提交)。两者都需要身份验证。

  5. Create the edit form component

    创建编辑表单组件。

    node ace make:page posts/edit

    现在添加表单标记。

    inertia/pages/posts/edit.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '@generated/data'
    import { Form, Link } from '@adonisjs/inertia/react'
    
    type PageProps = InertiaProps<{
      post: Data.Post
    }>
    
    export default function PostsEdit(props: PageProps) {
      const { post } = props
    
      return (
        <div className="form-container">
          <Link route="posts.show" routeParams={{ id: post.id }}>
            &lsaquo; Back to post
          </Link>
    
          <h1>Edit Post</h1>
    
          <Form route="posts.update" routeParams={{ id: post.id }}>
            {({ errors }) => (
              <>
                <div>
                  <label htmlFor="title">Post title</label>
                  <input
                    type="text"
                    name="title"
                    id="title"
                    defaultValue={post.title}
                    data-invalid={errors.title ? 'true' : undefined}
                  />
                  {errors.title && <div>{errors.title}</div>}
                </div>
    
                <div>
                  <label htmlFor="url">URL</label>
                  <input
                    type="url"
                    name="url"
                    id="url"
                    defaultValue={post.url}
                    data-invalid={errors.url ? 'true' : undefined}
                  />
                  {errors.url && <div>{errors.url}</div>}
                </div>
    
                <div>
                  <label htmlFor="summary">Short summary</label>
                  <textarea
                    name="summary"
                    id="summary"
                    rows={4}
                    defaultValue={post.summary}
                    data-invalid={errors.summary ? 'true' : undefined}
                  />
                  {errors.summary && <div>{errors.summary}</div>}
                </div>
    
                <div>
                  <button type="submit" className="button">
                    Update Post
                  </button>
                </div>
              </>
            )}
          </Form>
        </div>
      )
    }

    这个表单与创建表单相似,有几个关键区别:

    • 预填值:每个字段都显示当前文章数据(defaultValue={post.title} 等),以便用户看到自己正在编辑的内容
    • 路由:通过 routeParams 将文章 ID 包含在内,提交到 posts.update 路由。HTTP 方法(PUT)会根据路由定义自动推断
  6. Add edit button to post detail page

    现在为文章详情页添加一个 Edit 按钮。打开你的 posts/show.tsx 组件。

    inertia/pages/posts/show.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '@generated/data'
    import { Form, Link } from '@adonisjs/inertia/react'
    
    type PageProps = InertiaProps<{
      post: Data.Post.Variants['forDetailedView']
    }>
    
    export default function PostsShow(props: PageProps) {
      const { post } = props
    
      return (
        <div className="container">
          <Link route="posts.index">&lsaquo; Go back to posts listing</Link>
          <div>
            <h1>{post.title}</h1>
          </div>
    
          <div className="post">
            <div className="post-meta">
              <div>By {post.author.fullName}</div>
    
              <span>.</span>
              <div>
                <a href={post.url} target="_blank" rel="noreferrer">
                  {post.url}
                </a>
              </div>
            </div>
    
            <div className="post-summary">{post.summary}</div>
    
            <div className="post-actions">
              {post.can?.edit && (
                <Link route="posts.edit" routeParams={{ id: post.id }}>
                  Edit post
                </Link>
              )}
            </div>
    
            <div className="post-comments">
              {/* ... comments ... */}
            </div>
          </div>
        </div>
      )
    }

    我们使用 post.can?.edit 进行条件渲染,使 Edit 按钮只对文章所有者可见。这个 can 对象来自我们转换器的 forDetailedView 变体,而该变体使用了与控制器中相同的 PostPolicy

    非所有者甚至不会在组件中看到 Edit 按钮。如果有人尝试直接访问编辑 URL,他们仍然会从控制器的授权检查中得到 403 错误。

访问你创建的文章,你就会看到 Edit 按钮。点击它并尝试更新你的文章!

添加删除功能

  1. Add controller method

    删除文章比编辑更简单,因为不需要显示表单(只需一个提交 DELETE 请求的按钮)。让我们添加处理删除的控制器方法。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import PostTransformer from '#transformers/post_transformer'
    import { createPostValidator, updatePostValidator } from '#validators/post'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostsController {
      // ... existing methods
    
      /**
       * Delete a post
       */
      async destroy({ bouncer, params, response, session }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // Check if the user can delete this post
        await bouncer.with(PostPolicy).authorize('delete', post)
    
        await post.delete()
    
        session.flash('success', 'Post deleted successfully')
        return response.redirect().toRoute('posts.index')
      }
    }

    这种模式现在应该很熟悉了:找到文章,使用策略对操作进行授权,执行删除,flash 一条成功消息,然后重定向。删除文章后,我们重定向到文章列表页,因为文章详情页已经不存在了。

  2. Register the route

    现在注册删除路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
  3. Add delete button to post detail page

    在文章详情组件的 Edit 按钮旁边添加一个删除按钮。

    inertia/pages/posts/show.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '@generated/data'
    import { Form, Link } from '@adonisjs/inertia/react'
    
    type PageProps = InertiaProps<{
      post: Data.Post.Variants['forDetailedView']
    }>
    
    export default function PostsShow(props: PageProps) {
      const { post } = props
    
      return (
        <div className="container">
          <Link route="posts.index">&lsaquo; Go back to posts listing</Link>
          <div>
            <h1>{post.title}</h1>
          </div>
    
          <div className="post">
            <div className="post-meta">
              <div>By {post.author.fullName}</div>
    
              <span>.</span>
              <div>
                <a href={post.url} target="_blank" rel="noreferrer">
                  {post.url}
                </a>
              </div>
            </div>
    
            <div className="post-summary">{post.summary}</div>
    
            <div className="post-actions">
              {post.can?.edit && (
                <Link route="posts.edit" routeParams={{ id: post.id }}>
                  Edit post
                </Link>
              )}
    
              {post.can?.delete && (
                <>
                  <span>.</span>
                  <Form route="posts.destroy" routeParams={{ id: post.id }}>
                    {() => (
                      <button type="submit" className="destructive">
                        Delete
                      </button>
                    )}
                  </Form>
                </>
              )}
            </div>
    
            <div className="post-comments">
              {/* ... comments ... */}
            </div>
          </div>
        </div>
      )
    }

    关于这个删除按钮,有几个要点:

    • 授权检查 —— post.can?.delete 检查确保只有文章所有者能看到按钮
    • Form 组件 —— 即使是一个简单的删除操作,我们也使用 Form 组件来正确处理请求。HTTP 方法会根据路由定义自动推断

    试试看!访问你创建的文章,你会看到 Edit 和 Delete 两个按钮。访问其他人创建的文章,则不会显示任何按钮。

添加评论删除功能

  1. Update the Comment transformer to include authorization

    与文章类似,我们需要为评论添加授权数据。我们将使用之前学到的相同策略模式。在 CommentTransformer 中创建一个变体:

    app/transformers/comment_transformer.ts
    import { BaseTransformer } from '@adonisjs/core/transformers'
    import type Comment from '#models/comment'
    import UserTransformer from '#transformers/user_transformer'
    import { inject } from '@adonisjs/core'
    import { HttpContext } from '@adonisjs/core/http'
    import CommentPolicy from '#policies/comment_policy'
    
    export default class CommentTransformer extends BaseTransformer<Comment> {
      toObject() {
        return {
          ...this.pick(this.resource, ['id', 'content', 'createdAt']),
          author: UserTransformer.transform(this.resource.user),
        }
      }
    
      /**
       * Include authorization data for comments
       */
      @inject()
      async withAuthorization({ bouncer }: HttpContext) {
        return {
          ...this.toObject(),
          can: {
            delete: await bouncer.with(CommentPolicy).allows('delete', this.resource),
          },
        }
      }
    }
  2. Update Post transformer to use comment authorization variant

    现在更新 PostTransformer 以使用评论授权变体:

    app/transformers/post_transformer.ts
    import { BaseTransformer } from '@adonisjs/core/transformers'
    import type Post from '#models/post'
    import UserTransformer from '#transformers/user_transformer'
    import CommentTransformer from '#transformers/comment_transformer'
    import { inject } from '@adonisjs/core'
    import { HttpContext } from '@adonisjs/core/http'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostTransformer extends BaseTransformer<Post> {
      toObject() {
        return {
          ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']),
          author: UserTransformer.transform(this.resource.user),
          comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
        }
      }
    
      @inject()
      async forDetailedView({ bouncer }: HttpContext) {
        return {
          ...this.toObject(),
          comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)),
          comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments))
            .useVariant('withAuthorization')
            .depth(2),
          can: {
            edit: await bouncer.with(PostPolicy).allows('edit', this.resource),
            delete: await bouncer.with(PostPolicy).allows('delete', this.resource),
          },
        }
      }
    }
  3. Add controller method

    让我们添加用于删除评论的控制器方法。

    app/controllers/comments_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Comment from '#models/comment'
    import { createCommentValidator } from '#validators/comment'
    import CommentPolicy from '#policies/comment_policy'
    
    export default class CommentsController {
      // ... existing store method
    
      /**
       * Delete a comment
       */
      async destroy({ bouncer, params, response, session }: HttpContext) {
        const comment = await Comment.findOrFail(params.id)
    
        // Load the post so we can redirect back to it
        await comment.load('post')
    
        // Check if the user can delete this comment
        await bouncer.with(CommentPolicy).authorize('delete', comment)
    
        await comment.delete()
    
        session.flash('success', 'Comment deleted successfully')
        return response.redirect().toRoute('posts.show', { id: comment.post.id })
      }
    }

    这个方法的功能如下:

    • 通过路由参数中的 ID 找到评论
    • 加载 post 关联关系,以便我们能够访问 comment.post.id 用于删除后重定向
    • 使用 CommentPolicy 检查授权。如果用户不拥有该评论,会得到 403 错误
    • 从数据库删除评论
    • 重定向回显示该评论的文章详情页
  4. Register the route

    现在注册评论的删除路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
    
    router
      .delete('/comments/:id', [controllers.Comments, 'destroy'])
      .use(middleware.auth())
  5. Add delete button to comments

    最后,在文章详情组件的评论列表中添加删除按钮。

    inertia/pages/posts/show.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '@generated/data'
    import { Form, Link } from '@adonisjs/inertia/react'
    
    type PageProps = InertiaProps<{
      post: Data.Post.Variants['forDetailedView']
    }>
    
    export default function PostsShow(props: PageProps) {
      const { post } = props
    
      return (
        <div className="container">
          <Link route="posts.index">&lsaquo; Go back to posts listing</Link>
          <div>
            <h1>{post.title}</h1>
          </div>
    
          <div className="post">
            <div className="post-meta">{/* ... */}</div>
            <div className="post-summary">{post.summary}</div>
            <div className="post-actions">{/* ... */}</div>
    
            <div className="post-comments">
              <h2>Comments</h2>
    
              <div className="post-comment-form">{/* ... */}</div>
    
              {post.comments && post.comments.length > 0 ? (
                post.comments.map((comment) => (
                  <div key={comment.id} className="comment-item">
                    <p>{comment.content}</p>
                    <div className="comment-meta">
                      By {comment.author.fullName} on{' '}
                      {comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', {
                        month: 'short',
                        day: 'numeric',
                        year: 'numeric',
                      })}
                    </div>
                    <div className="comment-actions">
                      {comment.can?.delete && (
                        <Form route="comments.destroy" routeParams={{ id: comment.id }}>
                          {() => (
                            <button type="submit" className="destructive">
                              Delete
                            </button>
                          )}
                        </Form>
                      )}
                    </div>
                  </div>
                ))
              ) : (
                <p>No comments yet.</p>
              )}
            </div>
          </div>
        </div>
      )
    }

    评论删除按钮的工作方式如下:

    • 策略检查 —— comment.can?.delete 检查当前用户能否删除每条评论
    • 可见性 —— 只有评论的作者才会看到删除按钮
    • 操作 —— 按钮向 comments.destroy 路由提交 DELETE 请求

    访问你创建的带有评论的文章,你会看到自己评论旁边出现删除按钮。尝试查看其他用户的评论,则不会出现删除按钮。

你构建了什么

你已经成功使用 Bouncer 的策略系统为 DevShow 添加了授权。以下是你完成的成果:

  • 创建了 PostPolicyCommentPolicy,将所有权限逻辑集中到一处
  • 在控制器中使用 bouncer.with(Policy).authorize() 来在执行操作前强制实施权限
  • 学习了转换器变体 —— 同一资源可根据上下文输出多种形态
  • 使用 @inject() 装饰器在转换器变体中访问 HTTP 上下文
  • 使用策略在转换器中预计算用户权限,并将它们作为 can 标志发送给前端
  • 实现了完整的编辑文章功能(含表单、验证与授权)
  • 为文章和评论添加了带有正确权限检查的删除功能
  • 在 React 组件中使用条件渲染,使操作按钮仅对有权用户显示

这种方法的核心优势在于:你的授权逻辑完全位于后端,无法被绕过。Bouncer 策略运行在安全的环境中,转换器将预计算好的权限标志发送给 React 组件以做 UI 决策。这在保持安全性的同时,让你的前端保持简洁。