本页尚未翻译,当前显示英文版本。
Files & UGC
文件按分块到达,边到边处理。 上传、资源及其派生变体,以及带审核流程的玩家生成内容。
何时使用
- 玩家或服务要上传二进制数据:分块、可续传的上传会话(session),每个玩家的配额都可以读取。
- 上传还没结束就要开始处理:把文件当作流(stream)逐块读取。
- 玩家创作的内容需要审核流程:
SubmitUgc、队列、裁决,两端都有 hook。 - 一张母版图片要服务多个平台:派生出变体(缩放、转码),原图保持为规范版本。
- 无需使用:如果只是小型结构化 payload。Data & Subscriptions 记录中的一个字段就能承载,不需要上传会话。
分工
| 主体 | 在本页中的职责 |
|---|---|
player | 分块上传、读取流式文件、提交 UGC |
moderator | 审阅队列,批准或驳回提交的内容 |
backend-service | 派生资源变体;为上传和审核挂 hook;设置配额 |
概览
tank-07.png,上传途中读回,并作为贴花附加// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();
// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);
// attach to an entity
await tank.Attach("decal", file);// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();
// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);
// attach to an entity
await tank.attach('decal', file);# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()
# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
async for chunk in read:
ingest(chunk)
# attach to an entity
await tank.attach("decal", file)Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
{
if (!Result.HasValue()) { return; }
FPSUpload* Upload = Result.Value();
Upload->Parts->Create(PartNumber, Chunk);
Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
{
if (!Completed.HasValue()) { return; }
OnSkinUploaded(Completed.Value());
}));
}));
// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
[this](const TArray<uint8>& Chunk) { Ingest(Chunk); });
// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();
// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);
// attach to an entity
await tank.Attach("decal", file);UGC,玩家这条路径:
SubmitUgc:一次调用,所有绑定都一样var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // clconst submission = await playserv.files.submitUgc(file, { kind: 'level' }); // clsubmission = await playserv.files.submit_ugc(file, kind="level") # clGo 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
{
if (!Result.HasValue()) { return; }
Hud->ShowPending(Result.Value());
}));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.var submission = await playserv.Files.SubmitUgc(file, kind: "level"); // cl前后的把关都由 hook 完成,契约和其他地方一样:
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);
[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);export const checkUpload = before(Files.upload, (u: UploadIntent) =>
u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));
export const screen = after(Files.submitUgc,
(s: UgcSubmission) => playserv.files.moderation.enqueue(s));@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)
@after(files.submit_ugc)
async def screen(s: UgcSubmission):
await playserv.files.moderation.enqueue(s)Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。
hook 就是云函数(cloud function):它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unreal 订阅由此产生的事件。引擎内编写函数的方式已在计划中:Blueprint 图,以及 C++(转译为经平台校验的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍留在平台上。
hook 就是云函数(cloud function):它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unity 订阅由此产生的事件。
两个 hook 包裹的都是操作步骤,而不是事件:Before(Files.Upload) 决定上传是否开始,After(Files.SubmitUgc) 在提交生成后运行,并通过模块自带的 Moderation.Enqueue 把它放进审核队列。事件(上传完成、变体就绪、UGC 已提交、审核裁决)发给订阅者,而订阅者什么也否决不了。
模型
文件是不透明的字节加上声明的元数据(来源、内容类型、大小),它不用来存放决策所依据的状态。它与游戏模型的关联方向相反:你的类型上有一个字段保存引用;文件本身对游戏一无所知。
文件种类声明什么。
| 声明项 | 含义 |
|---|---|
origin | 创作内容、游戏生成或用户生成;大小限制和策略都由它决定 |
可接受的内容类型 | 以声明的列表给出,从不根据字节嗅探 |
大小限制 | 在打开会话时按声明的大小检查,而不是等到最后一块 |
分块大小与顺序 | 上传通过会话完成:声明的分块大小、分块顺序、续传点 |
derivatives | 可选,由处理器生成的具名变体。每个声明的变体是否就绪都可观测,所以客户端从不用猜缩略图有没有生成 |
存储前缀 | 位于保存引用的 schema 字段上:只说明字节存在哪里,别无其他。它不是目录:不能重命名、不能移动、前缀上没有权限,也没有递归操作。文件仍按 id 或 key 寻址,前缀不参与寻址 |
每个文件始终成立的事实。
| 始终成立 | 含义 |
|---|---|
完成 | 按会话幂等:重复完成返回同一个文件,而不是第二个 |
校验和 | 必填,不匹配就拒绝,绝不悄悄接受损坏的字节 |
已发布的文件 | 不可变:编辑即产生新版本,指向某个版本的引用始终指向原来的内容 |
创作内容 | 按 key 加版本寻址,而且是 managed:不在管理控制台中编辑,因为它归代码所有 |
未完成的会话 | 消亡过程可观测:超过截止时间后,平台终止它并发出事件,同时释放它的分块 |
所有权 | 遵循所有者谓词:用户生成和游戏生成的文件和任何有主的行一样有所有者,所有者的文件遵守玩家删除策略:级联删除、拒绝或匿名化,需要声明,而不是默认假定 |
读取权限 | 可以依赖权益(entitlement):付费资源由 Catalog & Commerce 的权益把关,而不是靠第二套权限系统 |
每个扩展点都指明它交给 hook 的类型:上传前是上传意图,提交后是提交本身,所以 hook 从不接收无类型的数据包。
错误
- 没有权益时返回
not found,而不是forbidden,这样拒绝列表不会暴露有哪些附加内容存在。已下架的文件也这样答复。 - 会话已过期属于冲突:打开一个新会话。
- 分块超出声明的顺序或大小、未声明的内容类型以及超出限制的大小都属于校验拒绝;其中大小问题在打开会话时就会报出,而不是等字节传完。
- 校验和不匹配属于值得重试的校验拒绝:重新发送该分块。
- 配额已用完属于冲突,释放空间后可以重试。
- 读取授权(grant)已过期返回 not authenticated:申请新的授权,而不要把它当作权限问题。
- 审核驳回是裁决,不是拒绝:提交已经过审议,答复是否定的,并附有声明的原因,所以下一步怎么做取决于原因。
- 超出上传频率时返回限流类别的错误,并附截止时间。
限制
具体数值会随平台限制一章给出;触及每个上限时的行为现在就已确定。
- 按来源划分的文件大小:平台在接受任何分块之前就拒绝上传,而不是等到最后一块。
- 分块大小:以校验失败拒绝该分块。
- 会话的有效期:转为
expired并发出事件,释放分块。 - 每个项目和每个玩家的存储配额:新会话以冲突拒绝,而且平台绝不为腾空间悄悄删除已发布的内容。
- 保留的创作内容版本数:最旧的版本下架,但生效环境引用的版本绝不下架。
- 每个主体的上传频率:限流,并给出截止时间。
- 游戏生成内容的保留期:超过期限后下架,并发出事件。