
之前在网上冲浪时看到“蟑螂也能机娘化”“我 chovy你玩过新时代的机娘吗”这类玩梗内容原本只是当段子一笑而过。但后来真正接触到机娘模型、国创机甲娘、以及大量二创“娃衣套装”时才发现这个圈子的资料管理已经远远不是“几张图片扔网盘”能解决的了。设计稿、套件图、穿着效果、部件命名、版本变更散落在各个群聊和文件夹里真要复用的时候翻图能翻到怀疑人生。本文就围绕这个问题用 Spring Boot Vue 3 SQLite 搭建一个轻量级的“角色图鉴与素材库管理系统”顺便把 AI 图片标签识别、素材归档、检索分类这些工程化思路一起落地。无论你是为了管理自己的机娘角色设定资料还是想在二次元素材工具方向做点实战练手这套代码都能直接跑起来扩展。1. 从玩梗到项目机娘角色素材到底缺什么1.1 机娘、蟑螂娘、娃衣套装这类素材的核心问题先说概念。“机娘”可以理解为把机械结构、装甲、武装元素与人物化造型结合的角色设计而“蟑螂娘”其实是一种拟人化创作方式把蟑螂的触角、外壳纹理、敏捷属性转化为角色特征本质上是“生物 机娘设计”的脑洞延伸。“娃衣套装”则更偏向实体周边或建模展示指为机娘人偶模型设计的可替换外甲、裙甲、武装配件。这类创作者和爱好者经常遇到的问题有三个素材形式多样既有设计原画也有 3D 渲染图、实体娃衣展示图、配件结构图。命名口径不统一同一个部件可能叫“蟑螂触角天线”也可能叫“雷达感应须”很难检索。版本迭代混乱V1 设计稿、V2 改制版、量产版、断版配色时间一长连作者自己都分不清。所以做一个“角色资料 图片素材 标签检索”的小系统比单纯存网盘更有价值。它能帮你把一套角色设定、多个套装部件、无数张图片统一归档并且支持按标签快速查找。1.2 项目目标做给谁用这篇文章的目标读者有三类二次元角色创作者和改娃爱好者希望建立自己的角色设定资料库。Java / Vue 全栈初学者想找一个能练手、又不至于太复杂的管理系统项目。想要入门“图片素材管理系统”开发的同学通过一个小而完整的例子理解文件上传、关系表设计、标签检索。读完本文你会掌握如何设计一套角色、套装、标签、图片素材的关系表结构。如何用 Spring Boot 编写上传接口、聚合查询接口和静态资源访问。如何用 Vue 3 Element Plus 做一套简单的后台管理页面。如何接入现有 AI 图像标签服务自动对图片生成标签并辅助归档。1.3 技术选型思路后端选择 Spring Boot是目前 Java 社区最主流的快速开发框架内置 Tomcat能极大减少配置工作量。Spring Data JPA 负责数据库操作。数据库用 SQLite 文件模式方便本地化使用不需要单独安装数据库服务对个人素材库足够友好。如果以后要多人协作可以替换成 MySQL代码改动主要集中在配置和方言上。前端使用 Vue 3 Element Plus。Vue 3 的 Composition API 更适合维护中大型前端项目Element Plus 提供了表格、表单、上传组件能大幅减少 UI 代码量。AI 图片标签能力并不需要自己训练模型直接调用云服务商的图像分析 API 即可当然也可以使用本地 ONNX 模型文章后面会给出两种思路的取舍。2. 环境准备与工程结构2.1 环境版本说明由于项目会随着时间升级这里不写死无法验证的具体版本号只给出建议环境环境建议JDKJDK 17 及以上构建工具Maven 3.6后端框架Spring Boot 3.xORMSpring Data JPA数据库SQLite文件模式可切换 MySQL前端Node.js 18Vite 5.x前端 UIVue 3 Element Plus图像标签云服务商图像标签 API 或本地 ONNX 模型如果你本机还没有 JDK 和 Node.js建议先安装然后通过java -version和node -v检查。2.2 整体工程结构实际开发中建议将后端和前端拆成两个目录便于独立部署。robo-girl-gallery/ ├── backend/ │ ├── pom.xml │ ├── src/main/java/com/example/gallery/ │ │ ├── GalleryApplication.java │ │ ├── entity/ │ │ ├── repository/ │ │ ├── controller/ │ │ ├── service/ │ │ └── config/ │ └── src/main/resources/ │ ├── application.yml │ └── static/upload/ └── frontend/ ├── package.json ├── vite.config.js └── src/ ├── main.js ├── App.vue └── api/之所以拆成两个工程是因为前端开发时可以使用 Vite 的热更新后端则专注于接口服务。生产环境下可以将前端的dist目录复制到 Spring Boot 的static目录也可以使用 Nginx 反向代理。2.3 数据表设计先来看核心的表结构这里考虑的是素材资料库的通用场景character角色表存角色名、简介、所属企划/系列。outfit套装表描述一套娃衣/外甲/武装组合的基本信息。part配件表描述套装里的单个部件。image_asset图片素材表保存图片路径、文件大小、分辨率、关联角色/套装/配件。tag和relation_tag标签表和多对多关系表方便通过标签检索。这种设计适合“一个角色可以有多套套装一套套装可以有多个配件一张图片可以同时属于多个对象”的复杂关系。下面的 SQL 是 SQLite 语法在其他数据库中使用时只需要微调字段类型。CREATE TABLE t_character ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, series_name TEXT, description TEXT, create_time TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_outfit ( id INTEGER PRIMARY KEY AUTOINCREMENT, character_id INTEGER NOT NULL, outfit_name TEXT NOT NULL, version_no TEXT, remark TEXT, create_time TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_part ( id INTEGER PRIMARY KEY AUTOINCREMENT, outfit_id INTEGER NOT NULL, part_name TEXT NOT NULL, part_type TEXT, material_info TEXT ); CREATE TABLE t_image_asset ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, file_path TEXT NOT NULL, file_size INTEGER, width INTEGER, height INTEGER, ref_type TEXT, ref_id INTEGER, upload_time TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, tag_name TEXT NOT NULL UNIQUE ); CREATE TABLE t_relation_tag ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, tag_id INTEGER NOT NULL );创建索引时重点考虑查询方向CREATE INDEX idx_outfit_character ON t_outfit(character_id); CREATE INDEX idx_part_outfit ON t_part(outfit_id); CREATE INDEX idx_image_ref ON t_image_asset(ref_type, ref_id); CREATE INDEX idx_relation_tag_id ON t_relation_tag(tag_id);索引不是越多越好而是要根据实际查询条件来创建。这个项目中最常见的场景就是“按角色查套装按套装查配件按标签查角色/套装/图片”所以索引建在这些关联字段上。3. Spring Boot 后端接口开发3.1 创建 Spring Boot 工程使用 Spring Initializr 创建工程或者手动拉取依赖。本项目核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.xerial/groupId artifactIdsqlite-jdbc/artifactId version3.45.1.0/version /dependency dependency groupIdorg.hibernate.orm/groupId artifactIdhibernate-community-dialects/artifactId /dependency dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId /dependency /dependencies需要特别说明的是Spring Boot 3.x 默认不支持 SQLite 方言因为 JPA 需要HibernateDialect才能正确生成 SQL。hibernate-community-dialects提供了SQLiteDialect需要在配置中显式指定。3.2 配置文件 application.yml在src/main/resources/application.yml中写入如下内容server: port: 8080 spring: datasource: url: jdbc:sqlite:./data/gallery.db driver-class-name: org.sqlite.JDBC username: password: jpa: database-platform: org.hibernate.community.dialect.SQLiteDialect hibernate: ddl-auto: update show-sql: true file: upload-dir: ./src/main/resources/static/upload这里有几个关键点ddl-auto设置为update可以在开发阶段自动更新表结构但生产环境建议改为validate或使用 Flyway 管理。上传目录要提前创建否则保存文件时会报FileNotFoundException。如果后续切换 MySQL只需要修改 URL、driver-class-name 和 database-platform。3.3 实体类代码下面是角色实体的写法其他实体模式类似// 文件路径backend/src/main/java/com/example/gallery/entity/CharacterEntity.java package com.example.gallery.entity; import jakarta.persistence.*; Entity Table(name t_character) public class CharacterEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private String name; Column(name series_name) private String seriesName; Column(columnDefinition TEXT) private String description; // getter、setter 省略实际代码请自行生成 }注意这里我在字段命名上使用了驼峰格式并通过Column(name ...)映射到数据库下划线字段。这是为了 Java 代码可读性更好也方便以后切换数据库。生成 getter/setter 时建议使用 IDEA 的快捷键不要手写大量重复代码。如果项目规模变大也可以用 Lombok 的Data注解但在新项目中需要评估是否接受“编译期自动生成代码”这一方式。3.4 Repository 层Spring Data JPA 的核心优势是根据方法名自动生成查询语句。比如我们需要根据角色 ID 查询套装列表只需要这样写// 文件路径backend/src/main/java/com/example/gallery/repository/OutfitRepository.java package com.example.gallery.repository; import com.example.gallery.entity.OutfitEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface OutfitRepository extends JpaRepositoryOutfitEntity, Long { ListOutfitEntity findByCharacterId(Long characterId); }如果需求复杂例如“同时根据角色名称模糊搜索和套装名称模糊搜索”可以继续扩展ListOutfitEntity findByCharacterNameContainingAndOutfitNameContaining( String characterName, String outfitName);这种方法名推导方式虽然高效但方法名过长后会失去可读性。遇到字段很多、条件动态变化的场景就应该使用Query或Specification。3.5 上传接口与静态映射图片上传是素材库的核心功能。我们先实现一个通用的图片上传接口// 文件路径backend/src/main/java/com/example/gallery/controller/FileController.java package com.example.gallery.controller; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.UUID; RestController RequestMapping(/api/file) public class FileController { Value(${file.upload-dir}) private String uploadDir; PostMapping(/upload) public MapString, Object upload(RequestParam(file) MultipartFile file) { MapString, Object result new HashMap(); if (file.isEmpty()) { result.put(code, 400); result.put(message, 文件为空); return result; } try { File dir new File(uploadDir); if (!dir.exists() dir.mkdirs()) { System.out.println(上传目录已创建: dir.getAbsolutePath()); } String originalFilename file.getOriginalFilename(); String ext ; if (originalFilename ! null originalFilename.contains(.)) { ext originalFilename.substring(originalFilename.lastIndexOf(.)); } String newFileName UUID.randomUUID() ext; File dest new File(dir, newFileName); file.transferTo(dest); result.put(code, 200); result.put(message, 上传成功); result.put(url, /upload/ newFileName); result.put(fileSize, file.getSize()); return result; } catch (IOException e) { result.put(code, 500); result.put(message, 上传失败: e.getMessage()); return result; } } }上传文件名使用 UUID 重命名可以避免不同用户上传同名文件互相覆盖。在真实项目中还需要做更多限制比如文件大小、扩展名白名单。接下来需要配置 Spring Boot 对/upload/**的静态资源映射// 文件路径backend/src/main/java/com/example/gallery/config/WebConfig.java package com.example.gallery.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.nio.file.Paths; Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath Paths.get(./src/main/resources/static/upload) .toAbsolutePath().toString(); registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }不配置这个映射的话上传成功后前端通过/upload/xxx.jpg访问图片会返回 404。3.6 角色聚合查询管理后台第一个页面通常会展示角色列表以及每个角色下有多少套套装、多少张图片。这需要聚合查询。简单项目里可以分多次查询再组装便于理解数据量大了再考虑用原生 SQL 或 View。下面演示专门写一个 DTO 来承接页面展示数据// 文件路径backend/src/main/java/com/example/gallery/dto/CharacterSummaryDTO.java package com.example.gallery.dto; public class CharacterSummaryDTO { private Long id; private String name; private String seriesName; private long outfitCount; private long imageCount; public CharacterSummaryDTO(Long id, String name, String seriesName, long outfitCount, long imageCount) { this.id id; this.name name; this.seriesName seriesName; this.outfitCount outfitCount; this.imageCount imageCount; } // getter、setter 省略 }在 Controller 中可以直接组装GetMapping(/characters) public ListCharacterSummaryDTO listCharacters() { ListCharacterEntity characters characterRepository.findAll(); return characters.stream().map(character - { long outfitCount outfitRepository.findByCharacterId(character.getId()).size(); long imageCount imageRepository.countByRefTypeAndRefId(character, character.getId()); return new CharacterSummaryDTO( character.getId(), character.getName(), character.getSeriesName(), outfitCount, imageCount ); }).toList(); }这种方式的缺点在于 N1 查询问题。现在只是为了个人数据库或者小规模管理可以接受。如果角色数量达到几千条就要改用一条分组统计 SQL 来优化。4. Vue 3 前端页面搭建4.1 初始化前端工程假设你已经通过 Vite 创建了 Vue 项目。在frontend目录下执行npm install element-plus axiosElement Plus 的完整引入方式很简单适合管理后台快速开发// 文件路径frontend/src/main.js import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)小项目用全量引入可以节省时间但打包体积会偏大。如果在意首屏性能可以参考 Element Plus 官方文档改成按需导入。4.2 封装后端请求为了统一处理接口返回建议把请求封装到一个模块里// 文件路径frontend/src/api/index.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.response.use( response response.data, error { ElMessage.error(error.response?.data?.message || 接口异常) return Promise.reject(error) } ) export function getCharacters() { return request.get(/characters) } export function getOutfits(characterId) { return request.get(/outfits, { params: { characterId } }) } export function uploadImage(file) { const formData new FormData() formData.append(file, file) return request.post(/file/upload, formData, { headers: { Content-Type: multipart/form-data } }) }这里将 baseURL 配置为/api开发环境下需要让 Vite 把请求代理到 Spring Boot 的 8080 端口。4.3 Vite 开发代理配置在frontend/vite.config.js中增加代理import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /upload: { target: http://localhost:8080, changeOrigin: true } } } })如果不配置代理浏览器会直接把请求发到前端自己的 5173 端口结果就是 404 或 405。4.4 角色管理页面角色列表页面是整个前端最核心的一页。这里用el-table展示角色用el-tag展示统计信息。!-- 文件路径frontend/src/components/CharacterList.vue -- template div el-button typeprimary clickhandleAdd新增角色/el-button el-table :datacharacters v-loadingloading border el-table-column propid labelID width80 / el-table-column propname label角色名 width200 / el-table-column propseriesName label所属系列 width180 / el-table-column label套装数 width100 template #default{ row } el-tag{{ row.outfitCount }}/el-tag /template /el-table-column el-table-column label图片数 width100 template #default{ row } el-tag typeinfo{{ row.imageCount }}/el-tag /template /el-table-column el-table-column label操作 template #default{ row } el-button link typeprimary clickviewDetail(row)查看详情/el-button /template /el-table-column /el-table /div /template script setup import { ref, onMounted } from vue import { getCharacters } from ../api/index const characters ref([]) const loading ref(false) async function loadCharacters() { loading.value true try { characters.value await getCharacters() } finally { loading.value false } } function handleAdd() { // 打开新增角色对话框可以自行补充表单实现 } function viewDetail(row) { console.log(查看角色详情, row) } onMounted(loadCharacters) /script这是一个非常标准的 Vue 3 页面骨架。实际项目中新增角色和查看详情往往会打开对话框或跳转到详情页逻辑会更多但核心数据流都是一样的加载列表、渲染、点击操作。5. AI 图片自动标签的接入思路5.1 为什么需要 AI 标签角色素材库图片一多靠人工一张张填分类会非常痛苦。比如你上传了一张“蟑螂娘”穿“武装外甲套装”的实物图理想情况是系统自动识别出“蟑螂”“机甲”“外甲”“银色涂装”等标签省去手动输入的时间。这里的 AI 能力不需要自己训练常见做法是调用云厂商的图像标签 API或者使用本地部署的通用图像识别模型。如果你的素材都是私人设计图传云端 API 会涉及隐私问题建议先确认素材是否允许上传到第三方服务。只上传脱敏后的预览图原图保存本地。或者在本地跑 ONNX 格式的轻量级图像分类模型。5.2 标签抽取流程一个实用的流程如下用户上传图片到后端。后端保存图片后把图片发送到 AI 服务。AI 返回标签列表例如[机甲, 银色, 改装, 天线, 人偶]。后端过滤空标签和低置信度标签。将标签写入t_tag表并在t_relation_tag表中维护图片与标签的关系。用文字描述就是这样上传 - 保存文件 - 调用图像标签API - 清洗标签 - 写入标签表 - 建立关联 - 返回图片ID没有使用 Mermaid 是因为在 Markdown 中这样的文字流程已经足够直观也更方便拷贝到笔记中。5.3 接口抽象与示例代码实际编写时可以定义一个接口方便替换不同 AI 服务商// 文件路径backend/src/main/java/com/example/gallery/service/ImageTagService.java package com.example.gallery.service; import java.util.List; public interface ImageTagService { ListString recognizeTags(String imagePath); }以“云服务商图像标签”为例代码结构大致如下但具体请求参数要参考你实际使用的服务Component public class CloudImageTagService implements ImageTagService { Override public ListString recognizeTags(String imagePath) { // 1. 读取图片并转为 Base64 // 2. 调用图像标签 API // 3. 解析响应中的标签名与置信度 // 这里只演示思路需要根据所选服务商调整 return List.of(); } }如果使用本地 ONNX 模型则需要额外引入 ONNX Runtime 依赖并将模型放在资源目录中。相比调用云端 API本地方式延迟更低也不依赖外网但对设备性能有一定要求且标签覆盖度通常不及云端大模型。这个模块不必第一步就开发。初期你完全可以选择“手动填标签”等素材量超过 500 张后再加入 AI 自动打标避免给项目引入不必要的复杂度。6. 常见问题与排查思路6.1 SQLite 连接与 JPA 初始化异常问题现象常见原因解决思路启动报SQLiteDialect找不到缺少hibernate-community-dialects依赖在 pom.xml 中添加该依赖database-platform配置不被识别依赖版本或 Spring Boot 版本不匹配确认 Spring Boot 3.x 与 Hibernate 6.x 兼容表没有自动创建ddl-auto被改成validate开发阶段改为update控制台 SQL 不打印没有开启show-sql在配置中设置show-sql: trueJPA 和 SQLite 的兼容性是一个高频坑点。如果感觉 Hibernate 对 SQLite 方言支持不够好纯后端阶段可以直接用 Spring JDBC JdbcTemplate省掉很多麻烦。6.2 图片上传失败问题现象常见原因解决思路上传时报FileNotFound上传目录不存在启动前创建目录或代码中主动mkdirs()上传成功后前端访问 404没有配置静态资源映射检查/upload/**是否映射到物理目录大图片上传超时Spring 默认单次请求大小为 1MB在配置中调大spring.servlet.multipart.max-file-size中文文件名乱码文件名编码处理不正确建议使用 UUID 重命名避免保存中文名关于大图片配置文件里可以这样设置spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB设置后需要重启后端服务才能生效。图片尺寸超过限制时会抛出MaxUploadSizeExceededException建议在全局异常处理器中统一转换提示信息。6.3 前端接口跨域问题开发环境中如果前端直接请求http://localhost:8080而不是走 Vite 代理就会遇到跨域。常见的排查顺序是检查前端请求 URL 是否带上了http://localhost:8080。检查 Viteproxy配置是否正确。如果后端单独开放给其他域名访问需要配置CorsFilter。其中最省心的方式是开发阶段全部走代理生产环境把前端构建产物放到 Nginx 或后端静态目录中从根源上规避跨域。6.4 AI 标签不准确AI 标签只是辅助不应该作为唯一归档依据。建议在界面上设计“人工确认标签”的交互自动识别后展示候选标签由用户勾选保存。对于“蟑螂娘”这种特定二次元风格素材通用模型很有可能会误识别成“昆虫”或“角色扮演”等宽泛标签需要人工校正。7. 工程落地建议与后续扩展7.1 素材命名与目录规范这是一个很值得提前规划的工程点。如果所有图片都堆在同一个目录将来清理时会很痛苦。建议按“对象类型 对象 ID 日期”组织物理目录例如upload/ ├── character/1/20250312_main.png ├── outfit/8/20250312_full_view.jpg ├── part/15/20250312_antenna.png └── temp/当然也可以继续使用 UUID 文件名但在数据库中保存业务对象 ID 的映射关系。两种方案没有绝对优劣只要保证“业务对象在数据库中的关系清晰、文件路径可反查”即可。对于自己维护的角色素材库我更推荐目录结构先分到对象级别。这样即使有一天数据库损坏还能根据文件目录大致还原出哪些角色有素材。7.2 数据库定期备份SQLite 是一个单文件数据库备份起来非常简单。只需要在数据量增长到一定规模后定期复制 gallery.db 文件到备份目录就足够了。不过要避免在写库过程中直接复制文件否则可能得到损坏的备份。更好的方式是使用 SQLite 的在线备份 API或执行如下命令生成一致性备份sqlite3 data/gallery.db .backup backup/gallery_20250312.db在管理后台也可以增加一个“一键备份”功能点击后调用后端接口执行备份并输出下载链接这对非技术人员非常友好。7.3 权限与内容边界素材库如果不只自己一个人使用就需要考虑最小权限原则。可以设计两类角色管理员可以上传、修改、删除素材。访客只能浏览和检索。对于上传素材的人还需要强调尊重原创版权。投稿人必须确认自己对素材拥有版权或已经获得授权否则不要往共享素材库里放。这是二次元素材平台最容易忽视的合规问题。7.4 更多可以扩展的方向到这里系统的核心架子已经完成了。如果你希望继续做深有几个方向可以选图片相似度检索使用图片特征向量支持“上传一张图找到同套装其他图片”。标签联动推荐浏览蟑螂娘的套装时如果该部件包含“装甲天线”标签自动推荐同标签的其他角色。低代码批处理接入定时任务每周自动清理临时文件、生成缩略图、计算素材存储容量。Web 端拖拽排序让每一套娃衣/武装部件在图库中支持可视化排序用于快速拼版预览。这些方向本质上都是在现有表结构上做增量。说明最初的数据建模思路没有走偏后续扩展会比较顺畅。说起这个项目最开心的时刻大概是把自己曾经收藏和设计的一堆“机娘改娃”素材全部导入系统然后点击“按标签检索”出来的那一瞬间——之前靠翻聊天记录和网盘目录才能找到的东西现在几秒钟就能定位到。如果你也管理着类似的角色灵感库或模型改件资料强烈建议照着这套代码先跑一遍再按自己的需求改一改。刚开始可能觉得表单和上传很繁琐但当数据库里的素材量一点点多起来你会发现自己已经离不开这套小工具了。