我先说结论:AnyPS5 不是一个多复杂的项目,它的本质就是一个“给自己用的游戏库记账本”。玩 PS5 到一定阶段,最大的困扰根本不是“没游戏玩”,而是“不知道下一款该玩什么”“哪张盘买了没通关”“愿望单里的游戏到底降没降价”。这些问题靠记忆和备忘录都解决不了,因为它们需要一个结构化的个人数据库。我花了大概三周业余时间,晚上和周末搭了这套工具。这篇文章不讲套话,只想把做设计时怎么想的、写代码时怎么拆的、后期踩过哪些坑,完整复盘一遍。无论你是 PS5 玩家想照着自己弄一套,还是纯粹想找个练手项目学 Web 开发,下面都应该有能直接抄的部分。
1. 项目定位与整体设计思路
1.1 动机:游戏库管理的三个真实痛点
先说说我自己的使用场景。我平时既有数字版游戏,也买实体盘,还经常在打折季顺手领一堆免费游戏。时间一长,问题集中在三块。
第一,库存完全无感。数字版藏在账号里,实体盘塞在柜子里,我根本说不清自己到底拥有多少款游戏。第二,游玩状态混乱。有些游戏打了 80% 放着,再拿起来已经忘了操作和剧情;有些白金了,隔半年会想不起这游戏到底值不值得推荐。第三,愿望单形同虚设。商店里加了一堆关注,但商店本身不会主动告诉我哪款降到心理价位了,我也不可能天天去刷。
这些痛点的共性在于:它们都需要一个“以我为中心”的数据视图。商店平台给的是“以商品为中心”的信息,适合浏览但不适合做个人管理。于是 AnyPS5 的核心定位就很清楚了——它不做商城、不做社交、不做云账号体系,只做一件事:把玩家拥有的游戏、游戏的状态、以及想买的游戏,统一管理在一个本地数据库里。数据完全归自己,想导出就导出,想改就改。
1.2 功能边界:刻意做“小”
动手之前,我先列了一个“绝对不做”的清单。很多人做个人项目容易失控,觉得什么都该加。我一开始也动过加社区评论、好友动态、推荐算法的念头,后来全部砍掉。原因很简单:一个单人维护的工具,每加一个功能,都要同步增加数据维护成本和崩溃排查成本。
最终我保留了五个功能模块:游戏入库、状态管理、愿望单、价格记录、统计面板,另外配了导入导出。这条边界花了两个晚上才想清楚,事实证明非常值得——因为砍掉的东西越多,真正留下的功能才能做得越顺手。
设计原则上,我坚持“离线和私有优先”。整个项目不部署任何云端服务,所有数据都落在本机 SQLite 文件里,平时断网也能正常使用。只有“价格更新”和“封面抓取”这两个动作需要联网,而且都是按需触发,不是后台常驻。这样即使某天外部数据源改版不可用了,游戏库本身依然完整可用。
1.3 技术选型:为什么是这套组合
技术选型这块我没有纠结太久,理由很直接。
后端用 Python 的 FastAPI,因为我的主语言就是 Python,写起来快,而且它处理 JSON 非常顺手,天然适合前端页面直接调接口。数据库用 SQLite,因为这是一个单用户的本地工具,并发量几乎为零,SQLite 一个文件搞定备份和迁移,比部署一套独立数据库省心得多。前端用 Vue 3 + Vite,我不需要服务端渲染,也不需要复杂的工程化,Vue 的响应式语法最适合我一晚上写一个页面的节奏。
整体不引入容器、队列、缓存中间件,理由和上面一样:个人工具的第一原则是维护成本最小化。如果一定要给个类比,AnyPS5 就像自己做一顿家常菜,不需要上酒店后厨的那套蒸烤箱和分子料理设备。工具越少,翻车概率越低。这也是我后面开发过程中体会最深的一点:很多项目不是死在大方向上,而是死在过于复杂的初期架构上。
2. 核心数据结构与关键功能拆解
2.1 游戏、版本、地区:数据模型怎么拆才不后悔
这是整个项目里我思考最久的部分。游戏实体和“游戏标题”之间不是一对一关系,比如某款大作既有标准版、豪华版、年度版,又有港版、日版、欧版,还有数字版和实体盘的区别。如果把这些全揉进一张表,后面每次查询都是灾难。
我最后的方案是把数据分成两层。第一层是 games 表,表示“一个游戏标题”这个抽象概念,记录游戏的全名、所属系列、类型、发行日期、封面路径、简介等基础信息。注意,这里不记录“我买了几份”,只记录“这是一款什么游戏”。第二层是 copies 表,表示“我实际拥有的一个副本”,每个副本关联一个 game_id,同时记录数字版还是实体版、什么区域、什么版本名、购买价格、入手渠道,以及实体盘存放位置。
这样设计的一个直接好处是:统计“库里有几款游戏”查 games 表记录数,统计“手上有哪几份实体盘”查 copies 表。两者口径不同,但都能通过一次简单查询得到。如果一个游戏我数字版和实体盘都买了,games 表里也只有一条记录,copies 表里则有两行。这种“标题—副本”的父子结构,是整个数据模型稳定性的基石。下面是 SQLAlchemy 里核心模型的关键部分。
# models.py 节选 from sqlalchemy import Column, Integer, String, Text, DateTime, func from database import Base class Game(Base): __tablename__ = "games" id = Column(Integer, primary_key=True) title = Column(String(200), nullable=False, index=True) series = Column(String(100), default="") genre = Column(String(50), default="") release_date = Column(String(20), default="") cover_path = Column(String(255), default="") # 本地封面文件路径 summary = Column(Text, default="") created_at = Column(DateTime, server_default=func.now()) status_lookup = Column(String(10), default="backlog") # 状态索引 progress = Column(Integer, default=0) # 0-100 进度copies 表的关键字段是 edition_name、edition_type(digital/disc)、region、purchase_price。这里有个细节:我把“当前游玩状态”放在 games 上,而不是 copies 上。虽然理论上同一款游戏买了两份可能一份通关一份没通,但实际使用中绝大多数人关心的还是“这游戏我通没通”。对少数双版本都有不同状态的情况,我做了妥协,统一以最新进度为准。这个决定简化了状态机复杂度,代价只是丢失了一个几乎不会用到的细分维度,非常划算。
2.2 游玩状态和进度:状态机要尽量简单
在状态设计上,我一开始列了六个状态:未入库、想玩、正在玩、已通关、已白金、已弃坑。后来发现“未入库”其实是对“不在库里”的重复描述,不如让库里只存在“我拥有或者我玩过”的游戏,于是砍到五个:想玩、进行中、已通关、已白金、已弃坑。
状态之间允许任意跳转,不设计强制顺序。有些游戏一周目通关后觉得没意思就弃坑了,也有游戏搁置半年后又捡起来继续,硬性流转反而是束缚。我给每个游戏只保留一个 progress 字段(0-100 的整数),白金直接就填 100。有人会问为什么不做自动同步奖杯接口,因为我试过对接授权流程,凭证维护麻烦,而且奖杯列表往往包含大量我没有的 DLC 奖杯,做出来可能比手动填还费劲。对个人项目来说,手填 30 秒的事,不值得用一个外部接口来替换。
这里有个细节值得单独提:状态变化时记录 updated_at,统计面板里就可以按月份画出“通关曲线”。这个功能其实是顺手加的,因为有了 updated_at,统计逻辑就只是按日期分组计数,代码不到二十行,但用起来特别有成就感。我的数据里能看见哪个月集中通关了三款游戏,哪几个月完全在摸鱼,挺有意思。
2.3 愿望单和价格记录:小心“提醒”功能失控
愿望单是游戏库的伴生模块。数据结构比游戏库简单:wishlist 表里存游戏标题、目标价格、当前价格、历史最低价、备注,以及一个 reached 布尔字段,表示是否已达到目标价。
价格数据怎么来?我提供两种途径。一是手动更新,打折季的时候自己看一眼填一下;二是自动抓取,通过发起带超时控制的 HTTP 请求,从公开商店页面按标题搜索拿返回的 JSON,解析出当前价和历史最低价的关键字段。抓取模块我写得非常保守,每次请求之间至少休眠 5 秒,失败就跳过并记录日志,同一个游戏的抓取结果缓存一周。原因有两个:高频请求必然导致访问被限制,得不偿失;价格本身不可能一天变好几次,批量更新就放在每周手动触发一次。
关于“提醒”,我刻意没有接入邮件或者桌面通知,而是在页面上用醒目颜色标注“低于目标价”。初衷是避免功能膨胀:邮件需要额外配置 SMTP,会引入新的故障点;用户打开 AnyPS5 首页看到提醒,本身已经足够。对个人工具来说,价值感来自数据可用,而不是通知有多即时。
3. 实操记录:从空目录到跑起来的 AnyPS5
3.1 环境初始化与项目结构
我建议把整个项目放在一个目录里,方便备份。以下是最终的项目结构,主干一目了然。
anyps5/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI 入口 │ │ ├── database.py # SQLite 连接与建表 │ │ ├── models.py # SQLAlchemy 模型 │ │ └── routers/ │ │ ├── games.py # 游戏库相关接口 │ │ ├── wishlist.py # 愿望单接口 │ │ └── stats.py # 统计接口 │ └── scripts/ │ ├── fetch_price.py # 价格抓取脚本 │ ├── export_data.py # 导出 JSON │ └── backup_db.py # 备份数据库 ├── frontend/ │ ├── src/ │ │ ├── pages/ # 列表页、详情页、统计页 │ │ └── components/ # 游戏卡片、状态标签等 │ └── package.json └── run.sh # 一键启动脚本环境搭建没什么玄学,我用 venv 隔离 Python 环境,前端用 npm 管理依赖,两个服务分别启动。开发阶段只在本机地址跑,不绑定多余端口,也不做域名配置。你可能会觉得这简单得不像一个完整项目——确实如此。对个人工具来说,把环境复杂度和运维复杂度压到最低,等于给自己省了大把周末时间。
3.2 元数据录入:自动匹配为辅、手动修正为主
游戏库最耗时间的不是写接口,而是录入数据。我一开始天真地以为能从第三方数据库一键导入全部游戏清单,实际上各家数据源要么接口权限复杂,要么字段覆盖不全,与其花时间对接维护,不如先把主体流程跑通。
我的实际录入流程分三步。第一步,从公开商店页面按标题搜索,拿到该游戏的名称、简介、封面图 URL 等基础 JSON 信息,存入 games 表。第二步,下载封面并保存为本地文件,cover_path 直接指向本地路径。第三步,手动补充发行日期、系列名,以及我自己关心的主观标签,比如“合家欢”“手柄体验好”。
这个流程里最需要注意的是错误处理。搜索接口偶尔返回空结果或者相似标题,我的策略是先展示匹配候选列表,由用户确认后写入。任何一步网络请求出错,都不影响已保存的数据。因为系统里的外部数据只是辅助录入,真正的主数据以用户确认为准。这个设计让 AnyPS5 对网络状态不敏感,实际体验很稳。
封面下载也有细节。我会先把 URL 统一处理成带扩展名的文件名,比如 1234_cover.jpg,避免 URL 含动态参数时存成没有扩展名的脏文件。如果下载失败,就留下空白路径,前端显示占位图,绝不阻塞录入流程。花几分钟处理的小逻辑,能避免后续几百条记录的脏数据问题。
3.3 后端接口与前端页面
后端接口尽量 REST 化,核心就几张表映射成几组接口。游戏库相关的接口大致是:
GET /api/games 查询游戏列表(支持关键词、状态过滤) POST /api/games 新增游戏 GET /api/games/{id} 查询游戏详情 PUT /api/games/{id} 更新游戏信息 DELETE /api/games/{id} 删除游戏 POST /api/games/import 导入 JSON 数据前端页面做了三块:列表页展示游戏卡片和快捷状态切换;详情页展示简介、封面、副本列表和编辑表单;统计页按状态、按年份、按月展示图表。列表页有一个关键交互:快捷键。我给自己留了数字键 1-5 快速切换状态的操作,不用进详情页就能完成日常维护。这个细节强烈推荐给做个人工具的朋友——自己用的工具,交互密度可以高得离谱,没人会觉得界面太“拥挤”。
前端技术栈很普通,组件化写法和大多数 Vue 项目一致。下面放一个最简单的游戏卡片组件片段,作为参考。
<!-- GameCard.vue(简化) --> <template> <div class="game-card" :class="'status-' + game.status_lookup"> <img :src="game.cover_path || placeholder" :alt="game.title" /> <h3>{{ game.title }}</h3> <span class="genre">{{ game.genre }}</span> <button @click="$emit('switch-status', game.id)"> 进度 {{ game.progress }}% </button> </div> </template>说实话,前端不是这个项目的重点,CSS 我也没有写得很精细,够用、能看、不别扭就行。真正让项目“好用”的是后端数据结构的合理性和快速的维护流程,而不是花哨的界面。
3.4 数据备份与导入导出
数据是个人工具最值钱的部分,我在备份上多花了一点心思。备份策略很朴素:每天用 cron 调用备份脚本,把 data 目录里的 SQLite 文件复制一份并加上日期后缀,保留最近 30 份。因为 SQLite 本身就是单文件,备份就是一次文件复制,几乎零成本。
导入导出用 JSON 格式,理由是想完整保留嵌套结构。导出的内容分为 games、copies、wishlist 三块,导入时执行三步操作:先按 title 判断是否已存在,不存在就新增;存在则更新非空字段;最后删除或者忽略重复的副本记录。为了防止误操作,导入前会自动生成一份当前数据库快照,万一导入结果不理想可以一键回滚。
这个功能一开始其实没打算做,后来发现在两台设备之间同步数据时特别需要它。如果把个人项目比作一个软件产品,导入导出就是它的数据自主权。有了 JSON 导出,就算以后不想用 AnyPS5 了,所有数据也还在自己手里,不会被任何工具绑架。
4. 踩坑实录与排查思路
4.1 同名游戏多版本归并的教训
第一个大坑是我早就料想到、但初期没处理好的问题:同名游戏出现多个版本。比如某款游戏发售时我录了标准版,后来年度版打折我又买了年度版,搜索结果一多,库里就出现两条看起来很像但又不一样的记录。最麻烦的是这两条记录本身应该归并成同一个“游戏标题”,才能正确统计“我拥有这款游戏”,并把标准版和年度版两个副本挂在下面。
初期全靠手工处理,游戏一多就撑不住。最终我写了一个归并工具:先把标题做规范化处理,去掉空格、统一大小写、忽略语言后缀,再用编辑距离阈值做相似度排序,把疑似重复的记录列出来供人工确认。记录多了以后,我大约手动确认了十几组合并,之后每次录入新游戏都会先搜索一次,避免重复造记录。
这里最大的体会是:数据模型的拆分和清洗工具一定要提前写。如果一开始把“标题”和“副本”混在同一张表里,归并的代价会比现在大十倍。
4.2 区域、语言和封面不一致
第二个坑是区域差异。同一款游戏,港版、日版、欧版的封面图、简介和标题语言都不一样。我最初统一用英文标题录入,结果搜出来的全是英文封面,完全失去了本地化的亲切感。后来我在 games 表外又加了一个 regions 字段,存 JSON 数组,记录“我关注哪些区域”,同时让副本表每行都带 region 和 language 两个字段。前端默认显示优先区域,找不到再显示备用区域的封面。
这个改动看起来小,但关系到使用感受——看到自己熟悉的封面,比滚动一页英文封面舒服太多。所以我特别建议做类似工具的人,从一开始就考虑“区域”这个维度,不要等数据录进去再回头补。
4.3 SQLite 加字段时的迁移麻烦
第三个坑来自数据库变更。我中途给 games 表加了一个 completion_date 字段,SQLite 的 ALTER TABLE 支持非常有限,不能直接修改列定义,只能 ADD COLUMN,而且新列如果带默认值,对已存在行的约束比较别扭。我踩坑的具体表现是:加了字段之后,老数据的该字段全是 NULL,统计页在分组时出现了空值行。
解决方法是写了一个简单的迁移脚本。
# migrate_add_completion_date.py import sqlite3 conn = sqlite3.connect("data/anyps5.db") try: conn.execute("ALTER TABLE games ADD COLUMN completion_date TEXT DEFAULT ''") except sqlite3.OperationalError: pass # 字段已存在,忽略 conn.commit() conn.close()这个脚本的核心是幂等,重复执行不会出错。从此以后,凡是改表结构的动作,我都写一个独立的数字开头迁移脚本,并记录当前 schema_version。虽然对小项目有点“杀鸡用牛刀”,但经历过一次手工改表导致的数据不一致之后,我觉得这个成本完全值得。
4.4 更新数据时的稳定性和频率控制
最后一个坑是关于外部数据更新的。最早写价格抓取的时候,没有加合理的延时和缓存,连续请求了几十次,很快就返回错误码。我当时的体感是:前一分钟还好好的,突然之间就完全不可用了。
修复方案正如前文所说:每个请求之间至少间隔 5 秒,所有结果缓存一周,失败后做指数退避重试,最多 3 次,重试时延递增。改完之后再跑几十次都很稳定。我特别想提醒看到这篇文章的朋友:任何依赖外部公开数据的工具,都必须把“对方随时可能拒绝服务”当作默认假设去设计。延时、缓存、退避、失败降级,这四样少一样,采集代码就是一颗定时炸弹。
5. 常见问题速查表
5.1 高频问题与对应处理
我把实际操作中遇到最多的问题整理成一张速查表,方便直接对照。
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| 搜索结果为空 | 网络失败或接口返回格式变化 | 查看运行日志,确认响应 JSON 字段是否变更,必要时手动补录 |
| 搜索到相似但不同的游戏 | 标题匹配太宽松 | 在候选列表中人工确认,再决定写入还是归并 |
| 封面图下载失败 | 图片地址失效或网络超时 | 给封面留空,前端显示占位图,后续手动补充 |
| 同款游戏统计出现两次 | 标题记录和副本记录混了 | 检查是否在错误表里重复创建,用归并工具清理 |
| 导入数据后出现重复游戏 | 导入时标题规范化不一致 | 先建唯一索引,再以标题加版本名作为合并键重新导入 |
| 本地库文件太大 | 封面图片存得太多 | 定期清理无效封面文件,用访问时间判断是否保留 |
这张表里的问题,多数来源于“数据源不稳定”和“数据模型不一致”两个根因,处理方式都围绕“手动确认 + 幂等操作 + 容错降级”展开。
5.2 提升日常使用幸福感的小建议
上面属于排除故障的层面,最后分享两个让日常使用更舒服的小技巧。第一个是给 run.sh 加一个简单的菜单式启动逻辑,输入 1 启动后端,输入 2 启动前端,输入 0 全部停止。不要小看这个脚本,它决定了你每次想打开游戏库时是“一条命令”还是“翻半天笔记”。第二个是每月初花五分钟做一次数据体检:看统计页里游戏数量、已完成数量、愿望单目标价是否还准确。这个小习惯能防止数据库悄悄长出脏数据,也让整个工具始终保持可信。
我个人在实际操作中的体会是,做 AnyPS5 最值得分享的经验就是一句话:个人工具的核心是数据自己说了算,功能只做刚需。我在写这个项目之前列过所谓核心功能清单,后来发现真正每天都在用的只有四个——添加游戏、改状态、看愿望单、看统计。其余那些想象中很美好的功能,上线半个月基本没碰过。
最后再分享一个小技巧:做这种个人管理工具,千万不要从“完美数据模型”开始。先手动录十款游戏,把最简单的列表页跑通,然后拿着这个粗糙版本用上一周,你自然而然就会发现真正需要优化的是哪个环节。手写的数据模型再难看,只要你在真实使用它,就会慢慢迭代成最适合自己的样子。这正是 AnyPS5 最让我满足的地方——它不漂亮、不花哨,但每一个字段都符合我自己的使用习惯。希望你能找到属于自己的那个“小而顺手”的工具。