ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零搭建开源CG/游戏资产管理平台:基于FastAPI的实战指南

2026/9/7 7:45:23 拓冰建站 浏览量
从零搭建开源CG/游戏资产管理平台:基于FastAPI的实战指南 在游戏和 CG 内容的日常生产中最让人头大的往往不是建模、画贴图或者写 Shader而是“找个文件找半小时”。项目做到中后期资产数量动辄上千命名混乱、版本错乱、团队拷贝来拷贝去最后谁也说不清哪个才是最终版。本文围绕“开源 CG/游戏 资产管理平台工具”这个方向从真实痛点出发梳理资产管理的核心概念、开源生态中常见的解决思路并带大家从零搭建一个基于 FastAPI 的可运行资产管理后台帮助有需要的人快速落地一套自己的资产管理工作流。先说一下适用读者如果你正在参与 CG 短片、独立游戏、UE/Unity 项目或者你所在的团队还在用“网盘 微信群 本地磁盘”管理资产这篇文章会很有参考价值。内容既有理论讲解也有完整代码示例和部署排错建议可以作为团队内部自建资产管理平台的第一版技术选型笔记。1. 为什么 CG/游戏团队需要资产管理平台很多初学者容易把“资产管理”理解成简单的文件管理。实际上CG 和游戏项目的资产不仅仅是一个三维模型文件或一张贴图它包含模型源文件Blender、Maya、3ds Max 等。贴图和材质相关文件。动画绑定数据。特效源文件。音频、视频、动作捕捉数据。策划表格、剧情脚本、UI 设计稿。引擎工程文件Unity/Unreal 工程。这些文件之间存在依赖关系。一个角色模型可能由模型源文件、多张贴图、若干材质球、绑定骨骼、动画 Clip 组成。如果只靠文件夹管理很容易出现以下情况。1.1 文件命名与存放规则无法持久项目初期团队会约定一套命名规范比如Char_Hero_Skeleton_v01.fbx。前期大家还能遵守但是到了赶工期随手保存成111.fbx、最终版.fbx、真最终版.fbx的情况就会出现。文件一多名称无法表达有效信息资产的可发现性大大降低。1.2 版本与协作冲突当多个美术同时修改同一个资产时如果没有一个统一平台就会产生多份副本。更加危险的是在引擎里引用资产时很容易引用到一个中间版本导致返工。资产管理平台的核心任务之一就是把“版本”和“引用”这两个状态管起来。1.3 缺少审阅和发布流程在正规 CG 制作流程中资产通常要经过 WIP工作版本、提交审阅、修改反馈、最终发布几个阶段。缺少流程约束就很难保证进入引擎的一定是已经审阅通过的资产。开源资产管理工具一般会提供“状态机”式的管理能力让每次文件流转都有记录。1.4 与游戏、CG 工具链集成困难美术在 Maya 里做好模型后需要一键发布到资产管理平台TA 在 Unity 里更新资产时需要从平台拉取新版本。这些动作如果靠手动效率极低。所以资产管理平台通常要提供 API 或命令行接口方便和 DCC 软件、游戏引擎串联。从业务价值来看资产管理平台解决的不只是“找文件”的痛点更是整个数字内容生产流程的质量保障。对于中小型团队来说自研一个轻量平台或选择合适的开源方案比盲目买商业软件更实际。2. 开源生态中常见的资产管理与数字内容平台看到这里你可能想知道目前业界有哪些开源方案可以参考。需要说明的是这个领域没有“银弹”不同方案适合不同规模的团队。下面按方向做一个归纳。2.1 制片管理与资产跟踪类这一类平台偏向“项目管理和流程审批”适合动画、影视制作团队。开源社区常见的代表思路包括通过任务Task驱动资产生命周期。将镜头、资产、人员、版本、审阅意见统一管理。提供网页审片、评论批注功能。有明确的发布和版本状态流转。例如 Kitsu 就是这种思路下的知名开源项目常用于 CG 制片管理与审阅。它的核心价值是“让流程驱动文件流转”并不只是存放文件的网盘。2.2 场景描述与资产规格标准类这类不是直接提供一个完整网站而是定义了一套数据模型和接口规范让多种 DCC 工具能够互相理解资产。比如 Universal Scene DescriptionUSD就是皮克斯开源的一套 3D 场景描述与交换格式它包含场景图、图层、引用等概念适合大型制作管线的跨软件协作。还有 OpenAssetIO它定义了一个与工具无关的资产管理接口层让插件与后端平台解耦。如果你打算自研平台建议认真研究 USD 和 OpenAssetIO 的规范。它们可以帮助你设计出不容易过时的元数据模型。2.3 游戏引擎资源管理相关方向游戏引擎中也有资产管理的概念例如 Unity 的 Addressables、Unreal 的 Content Browser 和虚拟化资产Virtualized Assets。不过这些工具更多解决的是“引擎内的引用与加载”而不是“团队间的文件流转与审阅”。很多游戏团队会同时使用一个团队级资产管理平台 引擎内的分包方案。在开源方向也可以关注社区里基于 Web 的轻量资产浏览工具。它们通常展示缩略图、标签、元数据适合与 Blender、Unity 等工具配合。2.4 自研 Web 资产管理后台如果你的团队需求比较特殊或者希望掌握全部代码可以考虑自研一个轻量 Web 资产管理工具。这也是本文后半部分的重点。我们的目标是实现一个最小可用的系统具备资产上传和元数据登记。文件哈希计算与去重。标签、类型检索。版本状态记录。简单的浏览器端预览入口。这样一套系统可以作为第一版快速跑起来后续按需扩展权限、审阅流程、DCC 插件等功能。3. 环境准备与项目结构为了让示例更贴近真实项目本文采用 Python 技术栈来搭建资产管理后台原因很简单Python 在 CG、DCC 脚本生态中使用广泛同时 FastAPI 开发效率高写 API 非常顺手。3.1 运行环境操作系统Windows / macOS / Linux 均可。Python3.10 或更高版本。数据库先用 SQLite生产环境可平滑切换到 PostgreSQL。存储方式本地文件系统生产环境可替换为对象存储。需要说明的是不同版本的 FastAPI 在接口写法上略有差异但本文使用的是非常稳定和常见的基础 API 用法。如果你的版本较新通常无需额外调整。3.2 项目依赖创建虚拟环境并安装依赖python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install fastapi uvicorn sqlalchemy python-multipart jinja2各依赖的作用fastapi提供 Web API 和路由。uvicornASGI 服务器负责启动 FastAPI 应用。sqlalchemyORM用来操作数据库表。python-multipartFastAPI 处理上传文件时需要。jinja2用于服务端渲染 HTML 模板方便展示前端页面。如果你的网络环境访问官方 PyPI 比较慢可以换成清华大学开源软件镜像站pip install -i https://pypi.tuna.tsinghua.edu.cn/simple fastapi uvicorn sqlalchemy python-multipart jinja23.3 项目结构asset-platform/ ├── app.py # FastAPI 主程序 ├── models.py # 数据库模型 ├── requirements.txt # 依赖列表 ├── data/ │ └── assets/ # 资产文件存储目录 └── templates/ └── index.html # 前端页面之所以采用这种简单结构是为了让初学者能一眼看清每个文件的作用。后续如果工程变大可以再拆分为 routers、services、schemas 等目录。4. 资产管理的核心模型拆解在动手写代码之前先理解资产管理平台最核心的数据模型这对后续扩展很重要。4.1 资产生命周期一个资产通常会经历以下状态WIP美术正在制作不对外公开。Submitted已提交审阅等待反馈。Approved已通过审阅可以进入生产流程。Published已发布可以被引擎或下游参考。Archived已归档不再被引用。在代码实现里我们可以用status字段保存这个状态。4.2 元数据字段一个基础的资产记录至少应该包含字段含义示例资产名称和文件解耦的稳定标识符hero_character资产类型模型、贴图、动画、音频等Texture / Model / Animation标签用于检索的短词角色、写实、T_Pose当前版本版本号3文件路径实际存储路径data/assets/uuid.fbx文件哈希用于校验和去重sha256 值文件大小字节数1024000上传者操作人admin状态生命周期状态published4.3 为什么不用文件名直接作为主键很多团队踩过的坑是用hero.fbx做资产唯一标识。但文件名可以被随意修改而且同名文件在不同目录下可能不是一个东西。更稳妥的做法是给每个资产一个内部 UUID同时保留原始文件名作为展示信息。python import uuid asset_id str(uuid.uuid4()) print(asset_id) # 输出类似1f2a3b4c-5d6e-4f8a-9b0c-123456789abc这里的asset_id是内部唯一标识对外展示则保留name字段。4.4 哈希与去重当你上传同一个文件两次时理想情况下系统应该识别出来并提示“文件已存在”。这需要通过计算文件的 SHA256 哈希来实现。import hashlib def compute_sha256(file_path): sha256 hashlib.sha256() with open(file_path, rb) as f: for block in iter(lambda: f.read(65536), b): sha256.update(block) return sha256.hexdigest()为何要分块读取因为一个贴图或模型可能达到 GB 级别一次性读入内存会非常吃内存。分块读取可以稳定处理大文件。4.5 版本管理策略推荐策略是“文件不可变版本递增”。每次上传新版本时系统生成一个新的物理文件名数据库记录增加一条新记录同时version字段加 1。这样即使新版本有问题也能随时切换回旧版本。5. 完整实战从零搭建开源式资产管理系统现在进入代码实战环节。为了不偏离主题我会把系统控制在最小可运行范围但保留资产管理最核心的能力。5.1 创建数据库模型文件路径models.pyfrom datetime import datetime from sqlalchemy import ( Column, DateTime, Integer, String, Text, ) from sqlalchemy.orm import declarative_base Base declarative_base() class Asset(Base): 资产记录表 __tablename__ assets id Column(Integer, primary_keyTrue, indexTrue, autoincrementTrue) # 内部唯一标识 asset_id Column(String(64), uniqueTrue, indexTrue, nullableFalse) # 展示名称 name Column(String(255), nullableFalse, indexTrue) # 原始文件名 original_name Column(String(255), nullableFalse) # 资产类型Model / Texture / Animation / Audio / Others asset_type Column(String(64), indexTrue, nullableFalse, defaultOthers) # 标签逗号分隔 tags Column(String(500), default) # 文件相对路径 file_path Column(String(500), nullableFalse) # 文件哈希 checksum Column(String(128), indexTrue, nullableFalse) # 文件大小 file_size Column(Integer, default0) # 版本号 version Column(Integer, default1) # 状态wip / submitted / approved / published / archived status Column(String(32), defaultwip) # 上传者 uploader Column(String(64), defaultadmin) # 创建时间 created_at Column(DateTime, defaultdatetime.utcnow) # 更新时间 updated_at Column(DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow)这里把asset_id设置为唯一索引方便通过 UUID 查询资产。checksum也加了索引便于做重复检测。5.2 编写 FastAPI 主程序文件路径app.pyimport hashlib import uuid from pathlib import Path from fastapi import FastAPI, File, Form, Request, UploadFile, HTTPException from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Asset, Base # ---------- 基础配置 ---------- BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data / assets DATA_DIR.mkdir(parentsTrue, exist_okTrue) DATABASE_URL sqlite:/// str(BASE_DIR / asset_platform.db) engine create_engine(DATABASE_URL, connect_args{check_same_thread: False}) SessionLocal sessionmaker(bindengine, autoflushFalse, autocommitFalse) Base.metadata.create_all(bindengine) app FastAPI(titleCG/游戏资产管理平台) templates Jinja2Templates(directorystr(BASE_DIR / templates)) app.mount(/static, StaticFiles(directorystr(BASE_DIR / static)), namestatic) # ---------- 工具函数 ---------- def compute_sha256(file_path: Path) - str: sha256 hashlib.sha256() with open(file_path, rb) as f: for block in iter(lambda: f.read(65536), b): sha256.update(block) return sha256.hexdigest() # ---------- 页面路由 ---------- app.get(/, response_classHTMLResponse) def index(request: Request): return templates.TemplateResponse(index.html, {request: request}) # ---------- API 路由 ---------- app.get(/api/assets) def list_assets(): db SessionLocal() try: assets db.query(Asset).order_by(Asset.id.desc()).all() data [] for asset in assets: data.append( { asset_id: asset.asset_id, name: asset.name, asset_type: asset.asset_type, tags: asset.tags, version: asset.version, status: asset.status, uploader: asset.uploader, file_size: asset.file_size, created_at: asset.created_at.strftime(%Y-%m-%d %H:%M:%S), } ) return JSONResponse(contentdata) finally: db.close() app.post(/api/assets/upload) async def upload_asset( name: str Form(...), asset_type: str Form(Others), tags: str Form(), file: UploadFile File(...), ): db SessionLocal() try: # 生成内部唯一标识 asset_id str(uuid.uuid4()) # 生成存储文件名避免中文、空格带来的路径问题 suffix Path(file.filename or ).suffix.lower() stored_name f{asset_id}{suffix} stored_path DATA_DIR / stored_name # 写入文件 content await file.read() stored_path.write_bytes(content) checksum compute_sha256(stored_path) file_size len(content) # 简单去重校验 exists db.query(Asset).filter(Asset.checksum checksum).first() if exists: # 删除刚写入的文件并提示用户 stored_path.unlink(missing_okTrue) return JSONResponse( status_code400, content{message: f该文件已存在原始资产名称为{exists.name}}, ) # 版本号默认从 1 开始 asset Asset( asset_idasset_id, namename, original_namefile.filename or , asset_typeasset_type, tagstags, file_pathstr(stored_path.relative_to(BASE_DIR)), checksumchecksum, file_sizefile_size, version1, statuswip, uploaderadmin, ) db.add(asset) db.commit() return RedirectResponse(url/, status_code303) except Exception as exc: db.rollback() return JSONResponse(status_code500, content{message: f上传失败{str(exc)}}) finally: db.close() app.post(/api/assets/{asset_id}/delete) def delete_asset(asset_id: str): db SessionLocal() try: asset db.query(Asset).filter(Asset.asset_id asset_id).first() if not asset: raise HTTPException(status_code404, detail资产不存在) # 删除物理文件 file_path BASE_DIR / asset.file_path if file_path.exists(): file_path.unlink() # 删除数据库记录 db.delete(asset) db.commit() return RedirectResponse(url/, status_code303) finally: db.close()这段代码中我刻意保留了db.rollback()和finally中的db.close()目的是强调数据库操作一定要正确处理连接释放与异常回滚否则在真实并发场景下会产生连接泄漏。5.3 编写前端页面文件路径templates/index.html这是一个简单的单页应用支持上传表单、资产卡片列表、搜索和删除操作。为了减少学习成本我只用原生 HTML JavaScript 实现。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleCG/游戏资产管理平台/title style body { font-family: Microsoft YaHei, sans-serif; background: #f5f7fa; margin: 0; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; } .card { background: #fff; border-radius: 10px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); padding: 20px; margin-bottom: 20px; } .upload-form input, .upload-form select { padding: 8px 12px; border: 1px solid #dce0e4; border-radius: 6px; margin-right: 8px; margin-bottom: 8px; } .btn { background: #1677ff; color: #fff; border: none; padding: 8px 18px; border-radius: 6px; cursor: pointer; } .btn-danger { background: #ff4d4f; color: #fff; border: none; padding: 4px 12px; border-radius: 6px; cursor: pointer; } .asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } .asset-item { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } .asset-item h3 { margin: 0 0 8px; font-size: 16px; } .asset-item .meta { color: #888; font-size: 13px; line-height: 1.6; } .tag { display: inline-block; background: #e8f1ff; color: #1677ff; border-radius: 4px; padding: 2px 8px; margin: 2px 4px 2px 0; font-size: 12px; } .search-box { margin-bottom: 12px; } .search-box input { width: 300px; padding: 8px 12px; border: 1px solid #dce0e4; border-radius: 6px; } /style /head body div classcontainer div classcard h2资产上传/h2 form action/api/assets/upload methodpost enctypemultipart/form-data input typetext namename placeholder资产名称如 hero_character required select nameasset_type option valueModelModel/option option valueTextureTexture/option option valueAnimationAnimation/option option valueAudioAudio/option option valueOthersOthers/option /select input typetext nametags placeholder标签逗号分隔如 角色,写实 input typefile namefile required button typesubmit classbtn上传资产/button /form /div div classcard h2资产库/h2 div classsearch-box input typetext idsearchInput placeholder按名称 / 标签 / 类型搜索 /div div classasset-grid idassetGrid p加载中.../p /div /div /div script async function loadAssets(keyword ) { const res await fetch(/api/assets); let assets await res.json(); if (keyword) { const kw keyword.toLowerCase(); assets assets.filter(item (item.name item.name.toLowerCase().includes(kw)) || (item.tags item.tags.toLowerCase().includes(kw)) || (item.asset_type item.asset_type.toLowerCase().includes(kw)) ); } const grid document.getElementById(assetGrid); if (assets.length 0) { grid.innerHTML p暂无资产/p; return; } grid.innerHTML assets.map(item div classasset-item h3${item.name}/h3 div classmeta类型${item.asset_type}/div div classmeta版本v${item.version}/div div classmeta状态${item.status}/div div classmeta上传者${item.uploader}/div div classmeta大小${formatSize(item.file_size)}/div div classmeta时间${item.created_at}/div div classmeta ${splitTags(item.tags).map(t span classtag${t}/span).join()} /div br button classbtn-danger onclickdeleteAsset(${item.asset_id})删除/button /div ).join(); } function splitTags(tags) { if (!tags) return []; return tags.split(,).map(t t.trim()).filter(Boolean); } function formatSize(size) { if (!size) return 0 B; if (size 1024) return size B; if (size 1024 * 1024) return (size / 1024).toFixed(2) KB; return (size / 1024 / 1024).toFixed(2) MB; } async function deleteAsset(assetId) { if (!confirm(确定删除该资产吗)) return; await fetch(/api/assets/${assetId}/delete, { method: POST }); loadAssets(document.getElementById(searchInput).value); } document.getElementById(searchInput).addEventListener(input, function () { loadAssets(this.value); }); loadAssets(); /script /body /html这里要解释一下我没有做登录鉴权删除按钮也没有二次输入密码确认这在生产环境是绝对不行的。示例的目的只是演示完整的资产上传、展示、删除流程安全加固会在第 7 节专门讨论。5.4 运行与验证编写requirements.txtfastapi uvicorn sqlalchemy python-multipart jinja2启动服务uvicorn app:app --reload --host 0.0.0.0 --port 8000然后打开浏览器访问http://127.0.0.1:8000。预期效果页面显示“资产上传”表单和“资产库”区域。选择一个本地模型文件或贴图上传填写资产名称与标签。提交后页面自动刷新资产卡片出现在资产库区域。使用搜索框输入关键词可以按名称、标签、类型过滤资产列表。当前版本不生成缩略图所以列表中没有预览图。这是后续可以扩展的方向之一。5.5 接口返回示例打开另一个终端执行curl http://127.0.0.1:8000/api/assets可能返回[ { asset_id: 8f2f90b5-0f1c-4f0a-9d5f-3c0e2a1b2c3d, name: hero_character, asset_type: Model, tags: 角色,写实, version: 1, status: wip, uploader: admin, file_size: 5643210, created_at: 2025-01-15 10:30:22 } ]这说明数据库写入、文件保存、API 查询都已正常运转。6. 常见问题与排查思路在实际搭建和使用过程中你可能会遇到以下几类问题。这里整理了常见的现象、原因与解决方案。问题现象常见原因解决思路上传大文件时浏览器一直转圈最后超时默认 Web 服务器或反向代理限制了请求体大小在 Nginx 中调整client_max_body_size同时在后端明确最大上传体积文件名包含中文或空格保存后路径错乱不同操作系统对中文文件名处理不一致存储时统一使用 UUID 重命名文件原始文件名仅保存到数据库上传同一个文件两次没有提示重复未计算哈希或哈希算法写错检查 SHA256 计算逻辑对相同哈希做数据库唯一约束接口返回 500日志显示数据库连接问题SQLite 在多线程下访问冲突使用check_same_threadFalse或生产环境更换 PostgreSQL前端页面样式丢失静态文件目录没有挂载正确检查app.mount(/static, StaticFiles(...))路径是否和模板中引用一致删除资产后文件还在磁盘删除逻辑只删数据库记录没有删除物理文件删除前定位file_path先删除文件后再提交数据库事务Windows 下路径拼接出现双反斜杠不同操作系统的路径分隔符不同统一使用pathlib.Path不要手动拼接/或\排查步骤建议先看服务端控制台日志确认报错位置。再确认数据库记录与物理文件是否一致。如果涉及上传要检查反向代理配置和网络延迟。如果是权限类报错查看目录读写权限。7. 工程化与最佳实践基础版跑通后距离真正可用的团队级资产管理平台还有不少距离。下面这些建议来自实际项目的通用经验可以帮助你少走弯路。7.1 授权与最小权限在团队环境中必须实现基于账号的权限管理。最基础的权限角色可以定义如下角色权限访客浏览、预览、下载已发布资产美术上传资产、更新版本、提交审阅组长审批资产、修改状态、管理成员管理员删除资产、修改配置、查看全量日志不建议让普通成员拥有删除权限。删除操作在生产环境中最好采用“软删除”方案也就是在数据库标记status archived而不是真正删除文件。7.2 文件哈希与完整性校验每次上传资产后平台应计算 SHA256 存入数据库。在资产下载或发布时再次计算哈希并进行比对。这样可以发现文件在传输或存储过程中是否损坏。7.3 版本控制与不可变存储物理文件命名采用“UUID 后缀”的好处是多次上传同一资产的不同版本时文件名不会冲突。数据库中通过name字段关联同一种资产用version字段区分版本。不要覆盖旧文件否则一旦新版本有问题很难回滚。7.4 安全与恶意文件防护资产管理平台往往会成为内网或公网环境中的一个“文件上传点”。需要注意三点限制可上传的扩展名例如.fbx、.ma、.blend、.png、.tga、.wav、.mp4等。对上传文件进行病毒扫描或至少在上传后隔离存储不要立刻执行任何动态解析。对用户输入进行转义防止 XSS 攻击。本文前端模板直接拼接了item.name在真实项目中必须转义。7.5 与 DCC 和游戏引擎集成资产管理平台的价值最终体现在工具链打通上。建议提供以下接口能力HTTP API支持上传、查询、下载、状态更新。命令行工具美术在 DCC 里调用asset-cli submit --name xxx完成发布。事件回调资产状态从 WIP 变为 Published 时通知相关下游系统。以 Blender 为例美术可以写一个插件利用平台 API 接收到本地资产列表再点击按钮一键发布import requests # 伪代码仅展示思路 def publish_asset(asset_path, name, asset_type): url http://asset-platform/api/assets/upload with open(asset_path, rb) as f: resp requests.post( url, data{ name: name, asset_type: asset_type, tags: blender,character, }, files{file: f}, ) return resp.status_code在 Unity 或 Unreal 中可以写一个 Editor 扩展菜单调用平台 API 拉取指定标签的新资产并解压到项目的Assets/Imported目录。这样团队内部可以逐步建立起一条从 DCC 到引擎的自动化管线。7.6 数据库备份与日志资产管理平台最核心的数据其实是“元数据”。文件丢了可以重新上传但数据库中的版本记录、审阅记录、标签信息一旦丢失恢复成本极高。一定要定期备份 SQLite 或 PostgreSQL。备份策略建议数据库每日增量备份每周全量备份。资产文件目录每日增量同步每月归档。备份验证要定期执行恢复演练不要只做备份不测试。7.7 性能优化方向当资产数量超过几万条SQLite 可能成为瓶颈。可以考虑将数据库切换为 PostgreSQL。文件元数据放入 Elasticsearch 或 OpenSearch。生成缩略图并做 CDN 缓存。下载流量大时把文件托管到对象存储平台由平台生成临时下载链接减轻后端压力。8. 总结与后续学习路线到这里我们从概念到代码完整实现了一个轻量级的开源 CG/游戏资产管理平台。你可以看到资产管理的核心不只是“上传和下载”而是围绕元数据、版本、状态、权限和工具链的一整套工程实践。当前示例已经具备的能力包括基于 FastAPI 的资产上传与查询 API。使用 SQLAlchemy 建立资产元数据模型。通过 SHA256 哈希实现文件重复检测。前端页面实现资产展示、搜索和删除。项目结构足够清晰适合继续扩展。接下来如果你想继续深入可以按下面的顺序进阶加入用户登录和权限系统例如使用 JWT 或 OAuth2。接入缩略图生成服务让模型、贴图、视频都能在浏览器端预览。实现“审阅流”功能支持评论、标注、审批驳回。研究 USD 和 OpenAssetIO 规范调整自己的数据模型。编写 Blender / Maya / Unity / Unreal 插件打通 DCC 和引擎。将本地文件存储替换为对象存储提高并发上传和下载能力。如果在搭建过程中遇到问题或者对资产管理平台的设计有自己的实践心得欢迎在评论区继续交流。动手实现一个最小版本远比把方案停留在 PPT 上有价值得多。