- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
导读
本篇文章基于 Redwood 教程第 7 章,讲解如何在 Redwood 的 Service 层通过context.currentUser获取当前登录用户,并据此实现「文章与作者关联」「作者姓名展示」「管理后台仅显示本人文章」「更新/删除前的所有权校验」等一整套多作者场景下的数据隔离方案。读完本文你将掌握:在 Prisma schema 中建立 User 与 Post 的一对多关系、编写 GraphQL relation resolver、利用@skipAuth/@requireAuth指令拆分公共接口与管理接口、通过服务组合(Service Composition)统一校验资源所有权,以及context在 Redwood 框架中的底层实现原理。
场景引入:从单作者博客到多作者平台
假设我们的博客已经成长为拥有多名作者的平台:有人负责写技术稿,有人负责写运营稿,而作为开发者的我们已经忙得没时间亲自写文章了。此时需要改动三件事:
- 把文章和作者关联起来,让每篇文章都能署名;
- 在阅读文章时展示作者姓名;
- 限制作者只能编辑自己的文章——Alice 不能篡改 Bob 的文章。
这三个需求分别对应「数据模型关系」「GraphQL 查询与展示」「基于登录用户的权限控制」,而贯穿其中的关键 API 就是 Redwood 的context.currentUser。
一、建立 Post 与 User 的关联(外键)
在 Redwood 中,数据模型由 Prisma Schema 定义。我们引入Post与User之间的一对多关系(一个User拥有多篇Post),这与教程前面章节中Post与Comment的关系类似:
┌─────────────────────┐ ┌───────────┐ │ User │ │ Post │ ├─────────────────────┤ ├───────────┤ │ id │───┐ │ id │ │ name │ │ │ title │ │ email │ │ │ body │ │ hashedPassword │ └──<│ userId │ │ ... │ │ createdAt │ └─────────────────────┘ └───────────┘这类数据变更操作会逐渐成为 Redwood 开发的日常,标准三步流程为:
- 在
schema.prisma中添加新关系; - 迁移数据库;
- 生成/更新 SDL 与 Service。
1.1 修改 Prisma Schema
在api/db/schema.prisma中为Post增加userId字段与指向User的关系,并在User侧补充反向关系posts:
model Post { id Int @id @default(autoincrement()) title String body String comments Comment[] user User @relation(fields: [userId], references: [id]) userId Int createdAt DateTime @default(now()) } model User { id Int @id @default(autoincrement()) name String? email String @unique hashedPassword String salt String resetToken String? resetTokenExpiresAt DateTime? roles String @default("moderator") posts Post[] }@relation(fields: [userId], references: [id])表示Post.userId是外键,引用User.id;User.posts是反向的列表字段。注意这里userId是必填字段,这是后续迁移会遇到坑的根源。
关于 User 的 SDL:在第 4 章配置认证时,
setup auth dbAuth命令已经在api/src/lib/和api/src/functions/中生成了直接使用 PrismaClient 操作 User 模型的认证文件,所以当时不需要为 User 单独建 SDL。但要让 GraphQL 能返回User类型(例如Post.user),还需要为 User 生成 SDL 与 Service,可执行yarn rw g sdl User --no-crud,并建议注释掉hashedPassword、salt、resetToken、resetTokenExpiresAt等敏感字段,防止它们通过 GraphQL 泄露。教程第 7 章 RBAC 一节(docs/versioned_docs/version-3.x/tutorial/chapter7/rbac.md)与此紧密相关。
1.2 迁移数据库:必填字段带来的坑
执行迁移命令:
yarn rw prisma migrate dev会立刻报错——这与之前给User添加roles字段时遇到的情况一致:userId是必填字段,但开发数据库中已经存在若干条Post记录,且 schema 中又没有为userId提供默认值,数据库无法在已有记录上补上这个非空列。
为什么不能偷懒写@default(1)?这个"快速方案"能绕过当前报错,但会在未来埋下难以排查的 bug:一旦忘记给post关联user,Prisma 不会报错,而是默默地把userId设为1——而 id 为 1 的用户可能根本不存在。正确做法是花点时间按规范处理,避免这些隐患。
既然处于开发阶段,最直接的办法是清空数据库重新开始:
yarn rw prisma migrate reset数据库种子(Seed)的连锁问题:如果你是基于 Redwood 教程仓库开始的后半程开发,重置数据库后种子脚本会报错——Prisma 会用一名用户和若干篇文章来填充数据库,但这些种子文章没有新增的必填字段
userId。需要打开scripts/seed.js,为每篇文章补充userId: 1:{ id: 1, name: 'John Doe', title: 'Welcome to the blog!', body: "...", userId: 1, },再次执行
yarn rw prisma migrate reset会得到另一个错误:因为reset并不会自动应用尚未执行的迁移,种子尝试写入一个数据库里还不存在的userId列(虽然 Prisma 生成的客户端已经包含该字段,所以它认为应该存在)。此时数据库实际已经重置成功,只是种子失败。接下来依次执行yarn rw prisma migrate dev(迁移,命名如 "add userId to post")和yarn rw prisma db seed(重新灌入种子数据)即可。
如果你的代码库不是从教程仓库而来,重置后数据库是空的:先访问http://localhost:8910/signup注册一个用户(先不要创建任何文章),然后把该用户的角色改为"admin"——既可以用上一节(RBAC)提到的 console 方式,也可以打开 Prisma Studio 直接在数据库里修改。
1.3 修改 SDL 与 Service
现在思考新关系在哪里展示。目前只需要在首页和文章详情页展示作者,因此 GraphQL 查询应能这样访问用户:
post { id title body createdAt user { name } }为此需要在 API 侧做两处修改:
- 在
postsSDL 中加入user字段; - 在
postsService 中为user编写关系解析器(relation resolver)。
给 Posts SDL 增加 user 字段
type Post { id: Int! title: String! body: String! createdAt: DateTime! user: User! }注意这里用的是User!(带感叹号):因为每篇Post都必然关联一个用户,该字段永远不会为null。
为什么不动 Mutation?我们故意不把
user或userId加进CreatePostInput/UpdatePostInput。虽然创建文章时需要为它指定作者,但不希望任何人都能通过 GraphQL 调用直接指定——否则可以轻松篡改请求载荷,把文章挂到别人名下。作者分配逻辑只放在 Service 内部,对外部世界不可操纵。
添加 User 的关系解析器
在api/src/services/posts/posts.js中,Service 函数之外再导出一个与模型同名(Post)的对象,其键名与需要被查询的字段(user)一致:
import { db } from 'src/lib/db' export const posts = () => { return db.post.findMany() } export const post = ({ id }) => { return db.post.findUnique({ where: { id }, }) } export const createPost = ({ input }) => { return db.post.create({ data: input, }) } export const updatePost = ({ id, input }) => { return db.post.update({ data: input, where: { id }, }) } export const deletePost = ({ id }) => { return db.post.delete({ where: { id }, }) } export const Post = { user: (_obj, { root }) => db.post.findFirst({ where: { id: root.id } }).user(), }一步步拆解这段看似反直觉的代码:
- 声明一个与服务对应的、与模型同名的变量
Post; - 把它设为对象,键名是需要被查询的字段名(这里是
user); - GraphQL 调用该函数时会传入若干参数,其中
root就是当前已解析出的父对象——在本例中即 GraphQL 查询里的post:
post { <- root id title body createdAt user { name } }这个post已经从数据库取出,我们能拿到它的id,于是调用root.id即可。接着用 Prisma 的findFirst()按这个 id 查到记录,再链式调用.user()返回与它关联的用户记录,而不是 post 本身。
这段解析器也可以等价地写成:
export const Post = { user: (_obj, { root }) => db.user.findFirst({ where: { id: root.userId } }), }一个值得注意的 GraphQL 特性:即使保留上述关系解析器,同时在posts/post返回的结果里也带上了user属性,字段解析器依然会被调用,其返回值会覆盖已有属性。原因在于 GraphQL 的机制——只要某命名字段存在解析器,它就会被执行并用返回值作为结果,哪怕root上已经携带了该数据。
Prisma 与 N+1 问题:熟悉数据库查询的同学可能已经发现,这种方式并不完美:每查到一篇 post,都要额外执行一次查询去取关联的 user 数据——这正是 GraphQL 中典型的 N+1 问题。其根源在于 GraphQL 查询的天然结构:每个解析器只知道自己父对象的信息,对潜在的子对象一无所知。
一个无需引入额外依赖的简单替代方案是移除该字段解析器,改为在查询 post 时直接
include用户数据:export const post = ({ id }) => { return db.post.findUnique({ where: { id }, include: { user: true, }, }) }但这种方式有利有弊:无论 GraphQL 查询是否请求了 user 数据,都会产生额外的数据加载开销;更重要的是它破坏了更深层的嵌套查询。例如想同时返回这篇文章的用户、以及该用户创作的所有其他文章 id:
post { id title body createdAt user { name posts { id } } }该查询会失败,因为返回结果里只有
post.user,没有post.user.posts。Redwood 团队一直在探索更优雅的 N+1 解决方案。
二、在页面上展示作者
要拿到作者信息,需要更新 Cell 的查询,在首页与文章详情页两处公开展示文章的地方把作者的name拉出来:
export const QUERY = gql` query ArticlesQuery { articles: posts { id title body createdAt user { name } } } `export const QUERY = gql` query ArticleQuery($id: Int!) { article: post(id: $id) { id title body createdAt user { name } } } `再更新展示 Article 的组件,在标题旁渲染 "by 作者名":
import { Link, routes } from '@redwoodjs/router' const Article = ({ article }) => { return ( <article> <header> <h2 className="text-xl text-blue-700 font-semibold"> <Link to={routes.article({ id: article.id })}>{article.title}</Link> <span className="ml-2 text-gray-400 font-normal"> by {article.user.name} </span> </h2> </header> <div className="mt-2 text-gray-900 font-light">{article.body}</div> </article> ) } export default Article在真正通过 scaffold 管理后台创建文章之前,还需要先解决"创建时如何把作者关联到文章"的问题。还记得我们刻意不允许通过 GraphQL 设置userId吗?scaffold 正是通过 GraphQL 创建/编辑记录的,所以这条路走不通——但没关系,我们本来就希望作者分配只发生在 Service 内部,这正是下一节要做的。
三、在 API 侧访问 currentUser
Redwood 中有一个「魔法变量」context,它在任何 Service 函数内部都可用,封装了该次 Service 调用所处的上下文。其中一个属性就是当前登录用户(如果确实有人登录的话)——它与 Web 侧可用的currentUser是同一个对象:
export const createPost = ({ input }) => { return db.post.create({ data: { ...input, userId: context.currentUser.id } }) }context.currentUser在需要访问"发起这次请求的用户"时始终可用。这里我们取用户的id,把它与 scaffold 表单传来的其余数据合并,作为新文章的userId。
3.1 context 的底层实现:异步存储代理
context之所以「魔法」,是因为它并非一个普通全局对象。在 Redwood 框架源码 packages/context/src/context.ts 中可以看到其实现:
export const createContextProxy = (target: GlobalContext) => { return new Proxy<GlobalContext>(target, { get: (_target, property: string) => { const store = getAsyncStoreInstance().getStore() const ctx = store?.get('context') || {} return ctx[property] }, set: (_target, property: string, newVal) => { const store = getAsyncStoreInstance().getStore() const ctx = store?.get('context') || {} ctx[property] = newVal store?.set('context', ctx) return true }, }) } export let context: GlobalContext = createContextProxy({}) export const setContext = (newContext: GlobalContext): GlobalContext => { context = createContextProxy(newContext) const store = getAsyncStoreInstance().getStore() store?.set('context', newContext) return context }context是基于AsyncLocalStorage的 Proxy 对象:每次请求进入时,框架把包含currentUser的上下文写入异步存储(setContext),Service 里读取context.currentUser时,Proxy 的get会从当前异步上下文中取真实值。这保证了并发请求之间上下文互不串扰——这正是context.currentUser在每次请求中"始终正确"的根本原因。在 packages/context/src/store.ts 中维护着这个 AsyncLocalStorage 实例。
同时,context是可扩展的:除了currentUser,你还可以往里面挂任何请求级数据,例如context.magicNumber = 1。
3.2 currentUser 从哪来
Web 侧的currentUser与 API 侧的context.currentUser内容一致,其来源是 API 侧api/src/lib/auth.ts中的getCurrentUser()函数(由认证框架调用),返回值会作为currentUser注入上下文。测试夹具 packages/graphql-server/src/functions/tests/fixtures/auth.ts 展示了典型的实现与校验逻辑:
// 用户已认证 ⇔ context 中存在 currentUser export const isAuthenticated = () => { return !!context.currentUser } // 校验 currentUser 是否被授予了某个角色 export const hasRole = ({ roles }) => { const currentUserRoles = context.currentUser?.roles if (typeof currentUserRoles === 'string') { return currentUserRoles === roles } // ... }完成上述改造后,通过管理后台创建文章应该能正常工作了,回到首页即可看到文章与作者的署名。
四、管理后台只显示自己的文章
目前任何 admin 访问/admin/posts都能看到所有文章。既然知道了context.currentUser的存在,我们可以在postsservice 中铺开使用它,把返回结果限定为当前登录用户拥有的文章:
import { db } from 'src/lib/db' export const posts = () => { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const post = ({ id }) => { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost = ({ input }) => { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost = ({ id, input }) => { return db.post.update({ data: input, where: { id }, }) } export const deletePost = ({ id }) => { return db.post.delete({ where: { id }, }) } export const Post = { user: (_obj, { root }) => db.post.findFirst({ where: { id: root.id } }).user(), }4.1 findUnique() 与 findFirst() 的选择
注意这里把findUnique()换成了findFirst()。Prisma 的findUnique()要求where子句中的字段具有唯一索引——id满足,但userId不满足。findFirst()则允许在where里放任意条件,可能返回多条记录,但 Prisma 只取第一条。这里因为同时按id和userId筛选,结果必然唯一,所以用findFirst()是安全的。
这些改动保证了用户只能看到自己的文章列表、或自己拥有的单篇文章详情。
4.2 新问题的浮现
但还有两个隐患:
updatePost和deletePost仍未受currentUser限制——任何人只要手工构造 GraphQL 调用,就能更新或删除任意文章;- 首页也使用
postsservice 展示全部文章——上述改动会让首页只显示当前登录用户自己的文章;而未登录用户访问首页时,context.currentUser不存在,直接报错。
如何让"管理后台返回一种列表,首页返回另一种列表"?这是下一节要解决的核心问题。
五、拆分出 AdminPosts Service
一个直观的思路是在 GraphQL 查询里加变量,并在现有postsservice 中做分支判断来区分首页/后台场景。但这种方式会显著增加测试面和脆弱性:后人很容易在加新条件或取反既有条件时不小心把后台能力暴露给外部攻击者。
更好的方案是为后台视图创建全新的 GraphQL 查询,借助@requireAuth自动获得安全检查,无需任何自定义代码。具体步骤:
- 创建定义类型的新
adminPostsSDL; - 创建新的
adminPostsservice; - 更新后台文章页面的 GraphQL 查询,改从
adminPosts取数。
5.1 创建 adminPosts SDL
保留现有posts.sdl.js作为"公共接口",复制一份命名为adminPosts.sdl.js并修改:
export const schema = gql` type Query { adminPosts: [Post!]! @requireAuth(roles: ["admin"]) adminPost(id: Int!): Post @requireAuth(roles: ["admin"]) } input CreatePostInput { title: String! body: String! } input UpdatePostInput { title: String body: String } type Mutation { createPost(input: CreatePostInput!): Post! @requireAuth(roles: ["admin"]) updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth(roles: ["admin"]) deletePost(id: Int!): Post! @requireAuth(roles: ["admin"]) } `export const schema = gql` type Post { id: Int! title: String! body: String! createdAt: DateTime! user: User! } type Query { posts: [Post!]! @skipAuth post(id: Int!): Post @skipAuth } `要点说明:
- 两个 SDL 共享同一个
Post类型(内部数据一致,由任一 SDL 返回的都是同一类型); - 从
postsSDL 中移除 mutations,公共接口不再需要它们;把 create/update/delete 三个 mutation 移到新的adminPostsSDL; - 两个查询由
posts→adminPosts、post→adminPost改名。全应用中每个 query/mutation 的名字必须唯一; adminPosts中的查询改用@requireAuth(带roles: ["admin"]即要求 admin 角色)取代原来的@skipAuth——现在有了专属后台查询,可以放心锁定为仅登录用户可访问。
5.2 创建 adminPosts Service
接下来创建adminPostsservice,把 create/update/delete 三个 mutation 迁移过去(SDL 文件名必须与 service 名一致):
import { db } from 'src/lib/db' export const adminPosts = () => { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const adminPost = ({ id }) => { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost = ({ input }) => { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost = ({ id, input }) => { return db.post.update({ data: input, where: { id }, }) } export const deletePost = ({ id }) => { return db.post.delete({ where: { id }, }) }(同样别忘了findUnique()→findFirst()的改动。)同时把posts精简回原来的公共职责:
import { db } from 'src/lib/db' export const posts = () => { return db.post.findMany() } export const post = ({ id }) => { return db.post.findUnique({ where: { id } }) } export const Post = { user: (_obj, { root }) => db.post.findFirst({ where: { id: root.id } }).user(), }我们移除了postsservice 中的userId过滤,恢复为返回全部文章(posts)或单篇文章(post,不区分归属)。
注意这里保留了posts中的关系解析器Post.user,而adminPosts中没有:由于两个 SDL 的查询与 mutation 返回的都是Post类型,需要把关系解析器保留在与原始 SDL 同名的 service 中:graphql/posts.sdl.js→services/posts/posts.js。
5.3 更新后台的 GraphQL 查询
最后更新 scaffold 生成的若干组件,让它们使用新的adminPosts/adminPost查询(下面只展示改动部分):
export const QUERY = gql` query FindPostById($id: Int!) { post: adminPost(id: $id) { id title body createdAt } } `export const QUERY = gql` query FindPostById($id: Int!) { post: adminPost(id: $id) { id title body createdAt } } `export const QUERY = gql` query POSTS { posts: adminPosts { id title body createdAt } } `posts: adminPosts这种 GraphQL 别名语法把查询结果重命名为posts,这样下方Success组件接收的 prop 名无需任何改动;如果不用别名,就得把Success组件的入参从posts改成adminPosts。
公共视图(如ArticleCell、ArticlesCell)无需改动,它们继续使用原来的posts/post查询及其对应解析器。
六、Update 与 Delete 的所有权校验
现在解决updatePost和deletePost。为什么不能直接这样写?
export const updatePost = ({ id, input }) => { return db.post.update({ data: input, where: { id, userId: context.currentUser.id }, }) }原因与findUnique()类似:Prisma 只允许基于唯一索引字段更新记录,这里只有id满足。where里必须只用id。那如何验证用户只能更新/删除自己拥有的记录?
方案是:先查询记录,确认归属,再执行更新:
import { ForbiddenError } from '@redwoodjs/graphql-server' export const updatePost = async ({ id, input }) => { if (await adminPost({ id })) { return db.post.update({ data: input, where: { id }, }) } else { throw new ForbiddenError("You don't have access to this post") } }这里复用的是adminPost()这个 service 函数,而不是再写一遍数据库查询(注意需要async/await先确认文章存在)。服务组合(service composition)正是 Redwood 刻意鼓励的写法:service 函数既是 GraphQL 的解析器,也是普通的 JavaScript 函数,可以在任何需要的地方被调用。其价值在此时体现得淋漓尽致——adminPost()已经内置了"仅返回当前登录用户拥有的文章"的逻辑,任何针对单篇文章的管理操作都经由这段代码、复用同一套校验逻辑。
由于deletePost也需要同样的检查,把归属校验抽取为独立函数:
const verifyOwnership = async ({ id }) => { if (await adminPost({ id })) { return true } else { throw new ForbiddenError("You don't have access to this post") } }最终的adminPostsservice 完整形态:
import { ForbiddenError } from '@redwoodjs/graphql-server' import { db } from 'src/lib/db' const verifyOwnership = async ({ id }) => { if (await adminPost({ id })) { return true } else { throw new ForbiddenError("You don't have access to this post") } } export const adminPosts = () => { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const adminPost = ({ id }) => { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost = ({ input }) => { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost = async ({ id, input }) => { await verifyOwnership({ id }) return db.post.update({ data: input, where: { id }, }) } export const deletePost = async ({ id }) => { await verifyOwnership({ id }) return db.post.delete({ where: { id }, }) }6.1 ForbiddenError 在框架中的实现
ForbiddenError由@redwoodjs/graphql-server提供,定义于 packages/graphql-server/src/errors.ts:
export class RedwoodGraphQLError extends GraphQLError { // 默认错误码 'REDWOODJS_ERROR' } export class ForbiddenError extends RedwoodGraphQLError { constructor(message: string) { super(message, { code: 'FORBIDDEN' }) Object.setPrototypeOf(this, ForbiddenError.prototype) } }它继承自 Apollo 风格的GraphQLError,抛出后 GraphQL 响应中会带有FORBIDDEN错误码,前端可据此统一处理"无权访问"的提示。同模块还提供了AuthenticationError(错误码UNAUTHENTICATED)用于"未登录"场景。
七、验证清单与测试建议
改完这些,务必逐条验证以下场景(这正是 QA 团队最爱做的事):
- 未登录用户可以看首页全部文章;
- 未登录用户可以查看文章详情;
- 未登录用户不能访问
/admin/posts; - 未登录用户不能看到评论旁的审核控制;
- 已登录 admin 用户可以在首页看到所有文章(而非只有自己的);
- 已登录 admin 用户可以进入
/admin/posts; - 已登录 admin 用户可以创建新文章;
- 已登录 admin 用户在
/admin/posts中看不到别人的文章; - 已登录 admin 用户默认不能看到评论审核控制(除非你在上一页末尾修改过该行为);
- 已登录 moderator 用户能看到评论审核控制;
- 已登录 moderator 用户不能访问
/admin/posts。
还可以为新功能补上自动化测试,防止将来被意外改动。最快的做法是为新 service 创建adminPosts.scenarios.js和adminPosts.test.js,验证"只返回给定用户拥有的文章"。在测试中可以通过 mockcurrentUser来模拟不同角色/未登录状态——Redwood 测试文档(docs/docs/testing.md)中介绍了 API 侧的mockCurrentUser。Cell 层也可以补测试,但其数据完全依赖 service 的返回,因此 service 层覆盖到位后基本可以放心。
如果哪里表现不对——有人看到太多或太少内容——请复查所有 GraphQL 查询是否都已更新、所有打开的文件是否都已保存。
结语
本篇文章完整走过了 Redwood 多作者场景的落地路径:从 Prisma 数据模型建立外键关系,到 GraphQL SDL/Service 的 relation resolver,再到context.currentUser在 API 侧的读取与底层异步存储实现,最后通过拆分adminPosts公共/管理双接口与服务组合统一校验所有权。这一整套模式——「公共查询用@skipAuth、管理查询用@requireAuth(roles: [...])、写操作在 Service 内做归属校验」——可以直接复用到评论、分类、标签等任何需要"按用户隔离数据"的业务模块中。
- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
相关推荐
Redwood 教程:在 API 侧安全访问 currentUser —— 从数据关联到按作者隔离的后台管理
Redwood 教程:在 API 侧安全访问 currentUser —— 从数据关联到按作者隔离的后台管理 本篇技术指南以 Redwood 教程第七章为骨架,
后端前端Web框架开发工具RedwoodJS 在 API 侧访问 currentUser:从数据关联到按用户隔离的完整实战
RedwoodJS 在 API 侧访问 currentUser:从数据关联到按用户隔离的完整实战 本文基于 RedwoodJS 教程第 7 章内容,完整讲解如何
后端前端Web框架开发工具3步轻松实现VR视频转2D播放的终极指南
3步轻松实现VR视频转2D播放的终极指南 想要在普通电脑上观看沉浸式VR视频吗? VR Reversal 为您提供最简单高效的 VR视频转换 方案。这款基于MP
后端前端Web框架开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考