Appearance
纹理处理与材质模块
本文引用的文件
- pipeline.go
- model.go
- delete.go
- texturemd.go
- web.go
- texmodel.go
- texdelete.go
- avatar.go
- avatargen.go
- webapi.go
- 0015_texture_public_dedupe.sql
- 0019_texture_tombstones.sql
- 0024_textures_public_upload.sql
目录
简介
本模块负责 Minecraft 皮肤与披风纹理的上传、校验、压缩、去重、预览与头像生成,以及最终写入数据库与文件系统。其安全关键路径位于“公网上传面”:所有用户上传的 PNG 必须先经过轻量 IHDR 预检、尺寸规则检查、严格解码、重新编码、内容寻址哈希,然后才进入数据库与文件系统。模型(Steve/Alex)由像素区域检测决定,而不是信任上传者声明;公共纹理通过 type:hash 唯一索引实现跨用户去重;删除采用软删除 tombstone 机制保留审计证据,并依据共享计数决定是否清理底层文件。
项目结构
纹理相关代码分布在以下子系统:
| 子系统 | 职责 | 关键文件 |
|---|---|---|
| Web API | 接收 /api/v1/me/textures 上传请求,校验表单、委托账户服务 | webapi.go |
| 账户服务 | 限流、配额、重复上传合并、调用存储层插入记录 | web.go |
| 纹理管道 | 读取 PNG、校验元数据、检测模型、压缩、去重、生成结果 | pipeline.go、model.go |
| 纹理存储 | 内容寻址文件系统,按 sha256 文件名读写 | pipeline.go、delete.go |
| 数据库层 | textures 表、公共去重索引、tombstone 审计表、引用查询 | web.go、texdelete.go、texmodel.go |
| 头像与预览 | 从已存储纹理渲染 2D/3D 头像及全身预览 | avatar.go、avatargen.go |
| 文本描述 | 对 texture description Markdown 做白名单清洗 | texturemd.go |
| 迁移脚本 | public_dedupe、tombstones、公共列表索引等结构变更 | 0015_texture_public_dedupe.sql、0019_texture_tombstones.sql、0024_textures_public_upload.sql |
图表来源
章节来源
核心组件
TexturePipeline:上传流水线
Process 是上传链路的处理器,依次执行:
- 大小限制:最多读取
MaxUploadBytes + 1字节,超过返回ErrTooLarge。 - IHDR 预检:不解码像素,只解析 PNG 签名和 IHDR 头,获取宽高,防止无效或畸形 PNG。
- 像素炸弹防护:
w × h > MaxPixels时拒绝,默认上限约一百万像素。 - 尺寸族校验:皮肤允许
64×32、64×64及其均匀整数倍;披风允许64×32、22×17及其均匀整数倍。 - 严格解码:仅接受
image/png。 - 模型检测与披风填充:皮肤调用
DetectSkinModel;披风若为22×17倍数则透明填充到最小64×32网格。 - 重新编码:丢弃原始 PNG 中的附加 chunk 和多态载荷,输出干净 PNG。
- 内容寻址哈希:对重新编码后的字节计算 sha256,作为文件名和缓存键。
- 返回 Result:包含 Hash、Type、Model、Bytes。
图表来源
章节来源
Texture 模型字段与哈希规则
纹理行存储在 textures 表中,关键字段如下:
| 字段 | 含义 | 说明 |
|---|---|---|
id | 自增主键 | 纹理记录 ID |
uid | 拥有者用户 ID | 上传或导入该纹理的用户 |
type | skin 或 cape | 区分皮肤与披风 |
hash | sha256 十六进制字符串 | 重新编码后 PNG 的内容地址,长度固定 64 |
model | default、slim 或空 | 仅皮肤有效,由像素检测得出 |
description | 人类可读名称 | 可空 |
detail_md | 用户描述的 Markdown | 经 texturemd 清洗后存储 |
source / source_url | 来源标识与链接 | 用于溯源 |
public | 布尔标志 | 是否公开展示 |
public_dedupe | type:hash 或空 | 公共纹理去重键,私有副本可为 NULL |
upload_at | 上传时间 | 排序与审计使用 |
moderation | 审核状态 | 管理用途 |
哈希计算规则:
- 不是原始上传字节,而是重新编码后的 PNG 字节。
- 使用 SHA-256,输出为小写十六进制字符串。
- 同一份重新编码内容无论被多少用户上传,只要公共可见就共享一个
type:hash唯一键。 - 私有纹理可以存在多份相同
hash但不同uid的行,因为它们的public_dedupe为 NULL。
图表来源
章节来源
模型识别:Steve 与 Alex
DetectSkinModel 的检测逻辑遵循 Mojang 的规则:
- 只有正方形且宽度是 64 的倍数时才可能携带 Slim 信息;否则一律返回
default。 - 对右臂第一层中两块“Slim 未使用区域”采样,如果任一区域出现透明度,则判定为
slim。 - 明确跳过 skinview3d 的左臂区域与纯色检测启发式,避免把传统 Steve 皮肤误判为 Alex。
- 非正方形或非 64 倍数尺寸,例如经典
64×32皮肤,不会触发 Slim 判断,因此需要库侧手动修正路径。
图表来源
章节来源
文本描述与摘要:texturemd
texturemd.Render 将用户提供的 Markdown 转换为安全 HTML:
- 使用 Goldmark GFM 扩展。
- 使用 bluemonday 白名单策略,仅允许段落、标题、粗斜体、列表、代码块、表格和 http(s) 链接。
- 禁止图片、iframe、表单、样式等远程资源,防止跟踪或 NSFW 内容随描述传播。
- 渲染失败回退为空字符串,不直接回显原始输入。
Excerpt提供纯文本摘要,适合元标签和卡片预览。
章节来源
文件系统存储与删除
texture.Store 是内容寻址文件系统:
- 目录由构造函数创建,文件名为 64 位十六进制 hash。
Path使用正则严格校验 hash,作为路径穿越防护。Save幂等写入:若文件已存在则跳过。Open用于读取已存储纹理,包括遗留迁移文件。Remove删除底层文件;缺失文件不算错误,因为数据库行可能已被其他并发删除先移除。
图表来源
章节来源
架构总览
图表来源
章节来源
详细组件分析
上传链路:webapi/upload → pipeline.Process → store.TextureInsert → filesystem 分发
Web API 入口
handleUploadTexture 的职责:
- 验证登录用户。
- 用
http.MaxBytesReader限制请求体大小。 - 解析 multipart 表单,失败时区分“超出站点限制”和“multipart 解析失败”。
- 校验
type必须为skin或cape。 - 读取
file、description、detail、source、sourceUrl、public。 - 调用
b.Account.UploadTextureMeta,将二进制 PNG 交给账户服务进一步处理。 - 根据是否新建记录返回
201 Created或200 OK,并返回id、type、hash、url、created、description、detail、source、sourceUrl、public。
章节来源
存储层插入与公共去重
AddTexture 用于公共纹理:
- 插入
uid、type、hash、model、description、detail_md、source、source_url。 public_dedupe设为type:hash。- 遇到唯一键冲突返回
ErrDuplicatePublic,表示已有相同内容的公共纹理。
AddTexturePrivate 用于私密导入:
- 设置
public = 0,public_dedupe = NULL。 - 允许相同
hash的多份私有副本共存。
TextureByOwnerHash 用于重试幂等:同一用户同类型同 hash 已存在时可直接复用。
PublicTextureByHash 只返回公共纹理,防止泄露其他用户的私有库。
图表来源
章节来源
软删除与孤儿文件清理:texdelete.go
DeleteTexture 的执行顺序:
- 开启事务并锁定待删行。
- 校验拥有者权限。
- 查询引用:玩家角色、衣橱条目、头像引用。
- 如果有引用且非强制删除,返回
ErrTextureInUse。 - 解除绑定:清除角色、衣橱、头像中的外键引用。
- 写入
texture_tombstones:保存删除前的type、hash、model、description、source、source_url、public、moderation、upload_at、deleted_by、deleted_at、reason。 - 删除
textures行。 - 统计剩余共享行数
ShareLeft。 - 提交事务。
注意:删除函数本身不删除底层文件。它返回 ShareLeft,由调用方在持有 hash 锁的情况下决定是否调用 Store.Remove。这避免了数据库删除成功但文件删除失败时回滚不一致的问题。
并发安全方面:
WithHashLock使用数据库会话级 advisory lock(MySQLGET_LOCK),SQLite 无跨进程锁则退化。- 锁名取
tex:加 hash 前缀,避免 MySQL 锁名过长。 - 获取失败返回
ErrBusy,调用方可重试。 - 注释明确要求:任何接触 hash 文件生命周期的操作都必须在这个锁内执行,且数据库行锁必须在 hash 锁之后获取,避免死锁。
图表来源
章节来源
3D 预览与头像生成
/avatar 与 /preview 接口由 avatar.go 暴露:
/avatar/player/{name}:玩家不存在返回 404;没有皮肤或文件则返回默认头像。/avatar/user/{uid}、/avatar/{tid}、/avatar/hash/{hash}:缺失时返回默认头像 200。- 非皮肤纹理返回 422。
- 支持
size(默认 100,限制 8–1024)、3d(生成 45° 立方体视角)、png(仅兼容参数)。 - 私有预览会设置
private, no-store和Vary: Cookie;公共预览可长期缓存。 - 支持插件代理:如果插件允许目标主机,可重定向到外部头像服务。
- 内部流程:打开
Store.Open(hash),调用avatargen.LoadSkinReader,再调用avatargen.RenderAvatar。
avatargen 负责标准 Minecraft 皮肤 UV 布局、左右肢体、头盔层、2D 头像、3D 头像、全身预览,并维护 RenderVersion 以破坏旧缓存。
图表来源
章节来源
模型回填与一致性:texmodel.go
SkinModelGroups 按 hash 分组查询皮肤行,收集每个内容地址当前持有的 model 值。 SetModelByHash 将指定 hash 的所有皮肤行统一更新为正确模型,忽略已经一致的行,保证回填任务幂等。
这解决了历史数据中 model 为空或与像素不一致的问题。
章节来源
依赖关系分析
图表来源
耦合与风险点
- Web API 与账户服务耦合:上传入口只做 HTTP 层校验,业务约束下移。
- 管道与文件系统解耦:
Result携带字节,存储层负责持久化。 - 删除逻辑与文件删除分离:数据库删除成功后,由外层决定文件清理,避免事务边界问题。
- 头像接口依赖
Store.Open,若 hash 损坏或缺失,应走默认头像路径。
章节来源
性能考虑
大图片裁剪建议
- 当前上传限制为 2 MiB,像素上限为 1M。虽然管线能处理最高约
2048×512的合法皮肤,但前端仍应在上传前裁剪到游戏支持的尺寸,减少网络传输、内存占用和重新编码成本。 - 对于超大图片,建议在浏览器端或网关层提前拒绝,避免浪费服务器 CPU 进行 PNG 解码。
并发限制建议
- 目前
Process同步读取全部输入、解码、编码,并返回字节给上层。高并发上传场景下,应避免无限 goroutine 并行解码大 PNG。 - 建议引入上传协程池或信号量,限制同时处理的上传数量。
WithHashLock已在数据库层对同一 hash 的写入/删除做串行化,但对不同 hash 的上传没有全局并发上限。
缓存命中率优化
- 公共纹理已通过
type:hash唯一索引去重,多次上传相同内容只需一次文件写入。 - 头像与预览接口支持长期不可变缓存,但需配合 URL 版本变化;
avatargen.RenderVersion在渲染器输出变化时必须递增。 - 公共皮肤列表使用了
(public, upload_at)索引,加速“最新公开纹理”分页。 - 可考虑在读取路径增加本地缓存:按 hash 缓存最近打开的文件句柄或解码后的皮肤对象,但需注意内存增长与过期策略。
I/O 与磁盘策略
- 内容寻址文件系统天然适合 deduplication;定期运行完整性校验可发现 hash 与文件名不一致的文件。
- 删除路径应先统计
ShareLeft,只有当值为 0 时才删除底层文件,避免孤儿文件堆积。 - 对大体积仓库建议启用独立日志分区,并监控
textures表与文件目录的大小比例。
[本节为通用性能建议,不直接分析具体源码行]
故障排查指南
模型识别错误
现象:Alex 皮肤显示为 Steve,或 Steve 皮肤被识别为 Alex。
诊断方法:
- 确认图片是否为正方形且宽度是 64 的倍数;非正方形尺寸不会触发 Slim 检测。
- 检查右臂第一层两块 Slim 未使用区域是否有透明像素。
- 如果是
64×32经典皮肤,系统不会自动判断 Slim,需要在库侧手动修正。 - 使用模型回填工具按 hash 批量校对并更新数据库。
参考实现位置
预览图失真
现象:3D 预览角度异常、身体或头部贴图错位、颜色异常。
诊断方法:
- 确认上传的是标准 Minecraft 皮肤布局,而非任意矩形图片。
- 检查管线是否对披风做了透明填充,避免披风误入头像路径。
- 核对
avatargen.RenderVersion是否与当前渲染器输出一致,避免浏览器缓存旧版预览。 - 检查
/avatar接口返回的size是否被限制在 8–1024。 - 如果使用的是插件头像代理,确认代理是否支持相同 UV 映射。
参考实现位置
重复上传未去重
现象:相同纹理上传多次,公共库中出现多条。
可能原因:
- 上传了不同 metadata(例如 description、source、detail_md)但像素完全相同,公共去重仍应命中
type:hash。 - 上传的是私有纹理,私有副本允许重复。
- 上传内容在重新编码后产生了不同字节,导致 hash 不同。
- 并发写入时唯一键冲突未被正确处理。
诊断方法:
- 比较两次上传返回的
hash。 - 检查
public是否为 true。 - 检查
public_dedupe列是否为type:hash。 - 检查数据库唯一索引
uk_textures_public_dedupe是否存在。 - 对可疑文件运行内容校验,确认文件名与实际 sha256 一致。
参考实现位置
删除后文件仍存在
现象:纹理行已删除,但磁盘上仍有对应 hash 文件。
诊断方法:
- 检查
ShareLeft:如果还有其他行引用该 hash,文件不应删除。 - 检查调用方是否在
DeleteTexture成功后调用了Store.Remove。 - 检查是否发生并发删除,另一个事务先释放了 hash 锁。
- 运行文件完整性校验,确认 hash 与文件名一致。
参考实现位置
描述 Markdown 被清空
现象:用户提交的 description 或 detail_md 渲染为空。
诊断方法:
- 检查是否包含被禁止的元素,如
<img>、<iframe>、<style>、远程资源。 - 检查链接是否使用 http(s),其他协议会被过滤。
- 检查渲染是否失败,失败时会降级为空字符串。
- 使用
Excerpt查看纯文本摘要是否正常。
参考实现位置
上传超时或内存压力
现象:高峰期上传慢、内存峰值高、CPU 被 PNG 解码占用。
诊断方法:
- 检查是否有限制并发上传的协程池。
- 检查上传文件大小是否接近 2 MiB。
- 检查前端是否提前裁剪图片。
- 检查是否频繁对超大 PNG 做 3D 预览渲染。
- 监控数据库 advisory lock 等待时间,确认 hash 锁是否成为瓶颈。
参考实现位置
结论
纹理处理与材质模块围绕“内容寻址 + 严格管线 + 软删除审计”构建。上传链路将安全风险前置到 IHDR 预检、像素上限、尺寸族校验和重新编码阶段;模型识别基于像素语义而非用户声明;公共纹理通过 type:hash 去重,私有纹理允许隔离副本;删除不直接删除文件,而是通过 tombstone 保留审计证据,并由共享计数驱动文件清理。头像与预览接口从已存储内容生成稳定输出,并通过版本化 URL 控制缓存失效。
未来优化应聚焦于上传并发控制、大图前端裁剪、缓存命中率提升、文件完整性巡检,以及对非正方形皮肤模型的自动化回填能力。这些改进可在不改变现有安全契约的前提下,显著降低内存、CPU 和磁盘开销。