Appearance
纹理上传与处理管线
本文引用的文件
- pipeline.go
- model.go
- delete.go
- texturemd.go
- webapi.go
- textures.go
- texdelete.go
- schema.go
- 0017_texture_metadata_reports.sql
- 0018_texture_moderation.sql
- 0019_texture_tombstones.sql
- 0024_textures_public_upload.sql
目录
简介
本文面向 SkinNexus 的“纹理上传与处理管线”,从客户端提交皮肤或披风图片开始,逐步说明:请求进入 webapi、校验大小与 MIME、PNG 解码、压缩重编码、模型检测、哈希计算、去重判断、写入内容地址存储、生成头像、可选 Markdown 描述解析、公开或私密状态设置。文档同时给出 Texture 数据模型字段及其业务含义,提供成功上传与常见失败场景的 HTTP 响应示例,并解释软删除、墓碑机制以及 texturemd 在元数据处理中的作用。最后给出异步解压、CDN 缓存、磁盘容量告警和去重等优化建议。
项目结构
该管线横跨三个层次:
- Web API 层:负责会话校验、表单解析、上传大小上限、错误码映射与最终 JSON 响应。
- 账户服务层:负责业务规则(类型、模型声明、描述长度、来源 URL、大小上限、审核拦截、锁与事务)。
- 纹理处理层:负责 PNG 解码、尺寸与像素炸弹防护、重编码、内容哈希、内容地址存储、steve/alex 模型检测。
图表来源
- webapi.go:309-310
- webapi.go:1037-1104
- textures.go:107-334
- pipeline.go:1-170
- model.go:1-68
- texturemd.go:1-66
章节来源
核心组件
- WebAPI 上传入口:
POST /api/v1/me/textures。先校验登录态,再对 multipart 请求体施加额外上限,解析type、file、description、detail、source、sourceUrl、public等字段,然后调用账户服务的UploadTextureMeta。 - 账户服务上传逻辑:校验类型、模型声明、UTF-8、名称与描述长度、来源与来源 URL 合法性;将字节数组交给纹理管道;检查违规墓碑;写入文件;按所有者哈希与公开哈希去重;插入
textures行;触发插件事件、审计与自动衣柜添加。 - 纹理管道:限制原始字节大小、扫描 IHDR、像素炸弹检查、尺寸家族校验、严格 PNG 解码、按类型处理 steve/alex 模型与披风补齐、PNG 重编码、SHA-256 取 hex 作为文件名与缓存键。
- 内容地址存储:以 64 位十六进制 hash 为文件名;存在即幂等跳过;提供
Path、Save、Open、Remove。 - 模型检测:仅对正方形且宽度是 64 倍数的皮肤图像,检测右臂区域透明像素来决定
slim(Alex)或default(Steve),不信任上传者声明。 - Markdown 描述处理:
texturemd使用受限渲染策略,只允许基础排版与 http(s) 链接,剥离远程图片与危险元素。 - 删除与墓碑:硬删除前复制当前行到
texture_tombstones,解绑玩家、衣柜、头像引用,保留举报历史,文件删除由上层根据共享计数决定。
章节来源
- webapi.go:1037-1104
- textures.go:107-334
- pipeline.go:1-170
- model.go:1-68
- texturemd.go:1-66
- delete.go:1-21
- texdelete.go:1-200
架构总览
纹理上传的生命周期可抽象为一次串行流水线,关键分支集中在账户服务层。
图表来源
详细组件分析
上传请求处理流程
WebAPI 的上传路由是 /api/v1/me/textures。它执行以下动作:
- 获取当前用户,未登录返回未认证错误。
- 对该路由放宽全局 JSON 体上限,改用
MaxBytesReader把请求体限制为MaxUploadBytes + uploadBodySlack。 - 解析 multipart 表单;如果因过大被截断,返回
413 Request Entity Too Large,并携带upload.too_large类代码。 - 校验
type必须为skin或cape。 - 读取
file字段,再次用LimitReader(MaxUploadBytes+1)防止超大文件撑满内存。 - 提取
description、detail、source、sourceUrl、public。 - 调用
Account.UploadTextureMeta。 - 根据是否创建新记录返回
201 Created或200 OK,并输出id、type、hash、url、created、description、detail、source、sourceUrl、public。
图表来源
章节来源
纹理管道:校验、解码、压缩与哈希
管道实现位于 texture.Process,注释明确列出阶段:
- 大小限制:读取最多
MaxUploadBytes + 1字节,超过返回ErrTooLarge。 - IHDR 预检:在完整解码前检查宽高,用于防 PNG 炸弹。
- 像素炸弹:宽高乘积超过
MaxPixels返回ErrPixelBomb。 - 尺寸家族:皮肤接受
64x32与64x64系列;披风接受64x32与22x17系列。 - 严格 PNG 解码:只接受
image/png,非 PNG 返回ErrBadPNG。 - 模型与补齐:皮肤调用
DetectSkinModel;披风对22x17家族透明补齐到 64 网格。 - 重编码:丢弃辅助 chunk 与多格式 payload,统一写回 PNG。
- 内容地址:对重编码后的字节做 SHA-256,hex 作为文件名与缓存键。
图表来源
章节来源
模型检测:steve/alex 判定
DetectSkinModel 不信任上传者声明,而是基于像素语义判断:
- 只有正方形且宽度为 64 倍数时才具备 slim 信息。
- 按 Mojang 规则检查右臂两个区域的透明度。
- 若区域透明则判定为
slim,否则default。 - 64x32 及非正方形纹理一律返回
default。
图表来源
章节来源
去重与公开/私密路径
账户服务在写入文件后执行多层去重:
- 所有者哈希去重:同一用户、同类型、同 hash 直接复用。
- 私密上传复用:私密上传遇到相同 hash 直接复用,不会变成公开。
- 公开库去重:公开上传遇到其他用户的相同 hash,且目标行是公开的,则视为公共库重复,返回已有纹理 ID 与页面 URL。
- 并发竞争:如果
AddTexture返回ErrDuplicatePublic,会再查一次所有者行或公开行,避免无意义的冲突。
图表来源
章节来源
删除与墓碑机制
删除不是简单物理删除整条记录。DeleteTexture 的工作顺序是:
- 锁定纹理行,校验所有者权限。
- 查询引用:角色皮肤/披风、衣柜条目、头像。
- 若非强制删除且有引用,返回
ErrTextureInUse。 - 解绑所有引用:角色设为 NULL、衣柜删除。
- 将当前行快照写入
texture_tombstones,包含hash、model、description、source、source_url、public、moderation、upload_at、deleted_by、deleted_at、reason。 - 删除
textures主行。 - 统计剩余共享行数,供上层决定是否删除底层文件。
图表来源
章节来源
元数据与 Markdown 解析
上传时除图片外还携带文本元数据:
description:短名称,最多 50 字符,支持站点命名正则。detail:长描述,最多 4000 字符,经texturemd.Render清洗。source:original、derivative、repost或空值。sourceUrl:衍生作品或转帖需提供合法的 http(s) 链接。public:默认公开,除非显式传false。
texturemd.Render 使用受限策略:
- 允许标题、段落、列表、表格、代码块等基础 Markdown。
- 仅允许 http(s) 链接,并要求
rel="nofollow"与referrerpolicy="no-referrer"。 - 拒绝图片、iframe、form、style 等远程资源,防止跟踪或 NSFW 内容随描述传播。
- 渲染失败降级为空字符串,不回显原始输入。
章节来源
Texture 模型字段与业务含义
虽然仓库没有单独导出 Go 结构体,但可从迁移文件与服务层推断出 textures 表的关键字段与含义:
| 字段 | 类型/约束 | 业务含义 |
|---|---|---|
id | 自增主键 | 纹理记录唯一标识。 |
uid | 用户 ID | 上传者的用户标识。 |
type | skin 或 cape | 纹理类型,影响尺寸规则、模型检测与访问路径。 |
hash | 64 位十六进制 | 重编码 PNG 的 SHA-256 hex,也是内容地址文件名。 |
model | default、slim 或 NULL | 仅 skin 有效,表示 Steve 或 Alex 手臂宽度,由像素检测得出。 |
description | 短文本 | 显示名称,受站点命名正则约束。 |
detail_md | 长文本 | 原始 Markdown 描述,前端展示时使用 texturemd 清洗后的 HTML。 |
source | 短枚举 | 来源标记,如 original、derivative、repost。 |
source_url | 可选 URL | 衍生作品或转帖的来源链接。 |
public | 布尔 | 是否进入公共皮肤库;公开纹理参与公共库去重。 |
report_count | 计数 | 累计举报数,用于管理面板与风险排序。 |
moderation_flag | 状态枚举 | 审核状态,例如空表示正常、violation 表示违规;违规内容禁止再次上传相同 hash。 |
created_at | 时间戳 | 记录创建时间。 |
updated_at | 时间戳 | 记录更新时间。 |
upload_at | 时间戳 | 实际上传时间,用于公开库排序索引。 |
其中,report_count 与 moderation_flag 来自报告与审核迁移;detail_md、source、source_url 来自元数据迁移;idx_textures_public_upload 为 (public, upload_at) 索引,支撑公开库“最新优先”的高效查询。
章节来源
- 0017_texture_metadata_reports.sql:1-20
- 0018_texture_moderation.sql:1-5
- 0019_texture_tombstones.sql:1-21
- 0024_textures_public_upload.sql:1-12
- textures.go:107-180
依赖关系分析
webapi依赖store.Mysql、texture.Store、ygg.Service与account.Service。account依赖store、texture、plugin、siteurl、ugctext。texture.pipeline依赖标准库crypto/sha256、image/png、io、os。texture.model依赖image/png进行像素检测。texture.delete依赖os与文件系统错误判断。store.texdelete依赖数据库方言、事务、事务锁与引用查询。
图表来源
章节来源
性能考虑
当前实现已经做了若干关键保护:
- 传输层:上传路由绕过全局 1 MiB JSON 上限,改用
MaxBytesReader(MaxUploadBytes + uploadBodySlack)。 - 解码层:先读有限字节,再扫描 IHDR,再限制像素数量,最后才完整解码 PNG。
- 存储层:内容地址化,相同字节幂等跳过。
- 并发层:
WithHashLock对每个 hash 做跨进程串行化,避免上传写文件与删除删文件竞态。
推荐进一步改进:
- 异步解压大图片:当前
png.Decode在请求 goroutine 中同步解码,大图可能阻塞线程。可将解码与重编码放入工作队列,上传先返回待处理状态,后台任务完成后更新状态并通知客户端。 - CDN 缓存静态
/textures/{hash}:内容地址天然适合 CDN;对公开纹理启用强缓存与 ETag,减少源站压力。注意私密纹理不应进入公共 CDN。 - 磁盘容量告警:
Store.Save直接写入磁盘,缺少配额与剩余空间检查。应监控textures目录总大小,并在接近阈值时拒绝新上传。 - 去重避免重复存储:当前
Store.Save已幂等,但若上游多次写入相同 hash,仍可能在事务完成前产生临时重复。可在保存前先Stat并加细粒度目录级锁,或引入对象存储版本控制。 - 批量重编码优化:对 HD 皮肤多次解码重编码成本较高,可考虑增量压缩、并行池与失败重试。
- 异步 Markdown 渲染:若描述较长,可在后台渲染 HTML 并缓存,降低首次展示延迟。
[本节为通用性能建议,不直接分析具体文件]
故障排查指南
成功上传
- 请求:
POST /api/v1/me/textures,multipart 包含type=skin、file、description、detail、source、sourceUrl、public=false。 - 成功响应:
- 新建:
201 Created - 复用:
200 OK
- 新建:
- 响应体关键字段:json
{ "id": 123, "type": "skin", "hash": "abc...def", "url": "/textures/abc...def", "created": true, "description": "我的皮肤", "detail": "<p>清洗后的描述</p>", "source": "original", "sourceUrl": "", "public": false }
尺寸超限
- 原因:上传 PNG 宽高不符合 skin 或 cape 家族,或像素总数超过
MaxPixels。 - 典型响应:
- 尺寸家族非法:
400 Bad Request - 像素炸弹:
400 Bad Request
- 尺寸家族非法:
- 响应体示例:json
{ "error": "texture.dimensions", "message": "纹理尺寸不属于接受的范围", "code": "texture.dimensions", "width": 100, "height": 100, "type": "skin" }
非 PNG
- 原因:PNG 解码失败,文件头无效或不是 image/png。
- 典型响应:
400 Bad Request - 响应体示例:json
{ "error": "texture.invalid", "message": "纹理格式无效", "code": "texture.invalid" }
重复 hash
- 情况一:同一用户上传相同 hash。
- 响应:
200 OK,返回已有纹理,created=false。
- 响应:
- 情况二:公开库已有相同 hash。
- 响应:
200 OK,返回公共库重复消息,附带已有纹理 ID 与页面 URL。
- 响应:
- 响应体示例:json
{ "error": "texture.duplicate_public", "message": "该皮肤已在公共库中", "code": "texture.duplicate_public", "textureId": 456, "url": "/skinlib/456" }
私密材质访问失败
- 私密纹理不应出现在公共皮肤库公开查询结果中;若直接访问公开 URL,取决于部署策略:
- 服务端未区分公开/私密读取:可能返回 200 和图片本身。
- 服务端按
public过滤:返回未找到或无权访问。
- 建议客户端对私密纹理使用带会话的私有接口,而不是直接使用
/textures/{hash}。
删除失败
- 仍在使用中:
409 Conflict或业务层still in use。 - 非所有者且非管理员:
403 Forbidden。 - 已被其他人抢先删除:
404 Not Found。
章节来源
结论
SkinNexus 的纹理上传管线采用“传输层限流 → 管道层安全解码 → 内容地址化存储 → 数据库行级去重 → 公开库独立去重”的分层设计。其安全性体现在:
- 上传体大小、PNG 尺寸、像素数量三重限制。
- 模型检测不信任客户端声明。
- 违规内容通过墓碑禁止再次上传相同 hash。
- 删除前解绑引用并保留审计证据。
- 公开库去重避免不同用户重复发布相同内容。
未来重点优化方向包括:异步解码大图片、CDN 缓存公开纹理、磁盘容量监控、更细粒度的去重锁,以及将 Markdown 渲染与头像生成进一步异步化,从而提升高并发上传时的稳定性与吞吐。