最近看到一条挺有意思的新闻话题:“Banksy works cost public almost £150k”。抛开艺术品争议不谈,这件事给技术人员留了一个很现实的思考:公共艺术品一旦进入城市,后续的清洁、修复、安保、保险甚至拆除,都会变成一笔笔公共支出。这些支出怎么登记、怎么统计、怎么防止重复花钱,光靠 Excel 和口头沟通肯定撑不住。
本文就围绕这个场景,从零到一搭建一套“公共艺术品资产与维护成本管理系统”。项目采用 Spring Boot + MyBatis-Plus + MySQL 做后端,Vue 3 + Element Plus 做前端,覆盖资产台账、维护工单、成本记录、年度统计等核心功能。无论你是做毕业设计、后端入门练习,还是想了解前后端分离项目的完整落地思路,都可以直接参考这套流程,并把代码复制到本地跑通。
1. 背景与核心概念
1.1 为什么公共艺术品需要一套管理系统
先看一个很常见的现象:城市里摆放的艺术装置、墙面壁画、路边的雕塑,大家平时只看到它作为“景观”存在,很少有人会追问它每年花了多少钱维护。但如果这些艺术品损坏、褪色、被涂鸦覆盖,就需要请专业人员清洁、修复;遇到安全隐患时,还要临时设置围挡、增加巡查;如果因为城市规划需要搬移,又会涉及运输、保险、场地恢复等成本。
这些费用分散在市政、文旅、园林、安保等多个部门,如果没有统一台账,很容易出现两个问题:
- 成本不透明:不同年份、不同部门花了多少钱,没有历史沉淀。
- 维护不及时:没有周期计划和巡检记录,小问题拖成大问题,最后不得不花更多钱修复。
所以,一套“公共艺术品资产管理系统”的核心价值,就是把每一件作品当成一项固定资产来管理,同时把与之相关的维护工单、成本明细记录下来,形成可查询、可统计、可追溯的数据资产。
1.2 系统的核心实体
1.2.1 资产台账
资产台账用来记录艺术品的基本信息,包括资产编号、作品名称、作者、所在位置、作品类型、当前状态、入库时间、所属部门等。这里的“资产编号”建议保持唯一,类似固定资产里的一物一码。
1.2.2 维护工单
当一件艺术品出现损坏、老化或者定期需要保养时,系统应该生成一张维护工单。工单里包含关联的艺术品、处理内容、紧急程度、当前状态、负责人、计划完成时间等信息。通过工单可以分析哪件作品维护频率最高,哪个位置容易出现异常。
1.2.3 成本记录
成本记录是整套系统里最敏感也最重要的数据。它记录的是每一笔与作品相关的真实费用,字段包括费用类型、金额、发生日期、支付部门、备注等。费用类型可以拆分为清洁、修复、拆除、安保、保险、运输等。
有了这些数据,我们才能回答类似“某一年哪一类艺术品的维护成本最高”这类问题。
1.3 核心业务流转
系统的核心流程可以拆成下面几步:
- 艺术品入库时,管理员在系统中录入资产台账。
- 巡检人员发现艺术品破损后,创建维护工单。
- 维护人员执行修复、清洁等工作,并登记对应的成本记录。
- 管理层按年度、月份、费用类型查看成本统计报表。
这套流程本质上是“资产档案 + 工单管理 + 成本归集”的组合,非常符合传统后台管理系统的典型形态。
2. 环境准备与版本说明
在开始写代码之前,先确认一下本地环境。不同版本之间会有细节差异,本文示例以常见组合为准,重点演示配置思路,你不需要严格追求最新版本。
建议环境如下:
- JDK 8 或 JDK 17
- Maven 3.6 或更高版本
- MySQL 5.7 或 8.0
- Node.js 14 或更高版本
- IDE:IntelliJ IDEA / VS Code 均可
后端框架版本方面,本文使用 Spring Boot 2.7.18 + MyBatis-Plus 3.5.5。如果你使用 Spring Boot 3.x,需要注意依赖坐标中javax.servlet会变成jakarta.servlet,MySQL 驱动坐标也会从mysql:mysql-connector-java调整为com.mysql:mysql-connector-j。
前端使用 Vue 3 + Vite + Element Plus + ECharts,这四个库都通过 npm 安装,版本只要保持较新即可,具体以安装时的 package.json 为准。
3. 数据库设计与 SQL 脚本
3.1 设计思路
这套系统的核心表有三张:artwork、maintenance_task、cost_record。
artwork:艺术品资产表,存放作品的基本档案。maintenance_task:维护工单表,记录某件作品被处理的任务。cost_record:成本记录表,记录与作品相关的费用明细。
三张表的关系是:一件作品可以有多条维护工单,一条维护工单可以关联多条成本记录;成本记录也可以直接挂在作品下,不依赖工单,方便处理“无工单但有费用”的场景。
为了保证数据的可追溯性,所有表都加上create_time、update_time,并保留deleted字段,用于逻辑删除。逻辑删除可以避免误删数据,后面会在 MyBatis-Plus 里统一配置。
3.2 建表 SQL
下面是完整的建库建表脚本:
CREATE DATABASE IF NOT EXISTS public_art_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE public_art_system; CREATE TABLE artwork ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', code VARCHAR(32) NOT NULL COMMENT '资产编号', name VARCHAR(128) NOT NULL COMMENT '作品名称', artist VARCHAR(64) DEFAULT '' COMMENT '作者', location VARCHAR(128) DEFAULT '' COMMENT '所在位置', type TINYINT DEFAULT 1 COMMENT '作品类型:1-壁画 2-雕塑 3-装置 4-其他', status TINYINT DEFAULT 1 COMMENT '状态:1-在展 2-维护中 3-已移除', install_date DATE DEFAULT NULL COMMENT '入库日期', owner_dept VARCHAR(64) DEFAULT '' COMMENT '所属部门', created_by VARCHAR(32) DEFAULT '' COMMENT '创建人', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除 1-已删除', UNIQUE KEY uk_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='艺术品资产表'; CREATE TABLE maintenance_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', artwork_id BIGINT NOT NULL COMMENT '艺术品ID', task_no VARCHAR(32) NOT NULL COMMENT '工单编号', title VARCHAR(128) NOT NULL COMMENT '工单标题', description VARCHAR(500) DEFAULT '' COMMENT '问题描述', priority TINYINT DEFAULT 2 COMMENT '优先级:1-低 2-中 3-高', status TINYINT DEFAULT 1 COMMENT '状态:1-待处理 2-处理中 3-已完成 4-已取消', assignee VARCHAR(32) DEFAULT '' COMMENT '负责人', plan_finish_date DATE DEFAULT NULL COMMENT '计划完成日期', finish_time DATETIME DEFAULT NULL COMMENT '实际完成时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除', UNIQUE KEY uk_task_no (task_no), KEY idx_artwork_id (artwork_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维护工单表'; CREATE TABLE cost_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', artwork_id BIGINT NOT NULL COMMENT '艺术品ID', task_id BIGINT DEFAULT NULL COMMENT '关联工单ID,可以为空', cost_type VARCHAR(32) NOT NULL COMMENT '费用类型:清洁/修复/拆除/安保/保险/运输', amount DECIMAL(12, 2) NOT NULL COMMENT '金额', occurred_date DATE NOT NULL COMMENT '费用发生日期', pay_dept VARCHAR(64) DEFAULT '' COMMENT '支付部门', remark VARCHAR(255) DEFAULT '' COMMENT '备注', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除', KEY idx_artwork_id (artwork_id), KEY idx_task_id (task_id), KEY idx_occurred_date (occurred_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成本记录表';字段设计时有两个地方要特别说明:
一是金额字段使用DECIMAL(12, 2),不要使用FLOAT或DOUBLE,否则做累计求和时可能出现精度误差。二是所有关键字段都建立索引,包括artwork_id和occurred_date,这样后续按日期范围统计成本时性能才可能有保障。
4. Spring Boot 后端实现
4.1 创建项目与 Maven 依赖
后端项目推荐用 IDEA 直接创建 Spring Initializr 项目,也可以手动建立 Maven 工程。核心依赖如下:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>public-art-system</artifactId> <version>1.0.0</version> <name>public-art-system</name> <description>公共艺术品资产与维护成本管理系统</description> <properties> <java.version>8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>这里用到了 MyBatis-Plus 的BaseMapper,它可以帮我们省去大量手写 SQL 的重复工作。如果你不想引入 MyBatis-Plus,也可以直接用 Spring Data JPA 或原生 MyBatis,核心实现思路都差不多。
4.2 application.yml 配置
在src/main/resources下创建application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/public_art_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 id-type: auto配置说明:
map-underscore-to-camel-case:开启驼峰映射,让数据库的install_date自动映射到 Java 字段installDate。logic-delete-field:配置逻辑删除字段,后续调用删除方法时,真正执行的其实是UPDATE artwork SET deleted = 1 WHERE id = ?。jackson.date-format:统一返回给前端的时间格式,避免出现时间戳。
如果你的数据库密码或端口不同,记得改成自己的配置。生产环境不要使用明文密码,建议通过环境变量或配置中心注入。
4.3 实体类与表映射
创建entity包,新建Artwork.java:
package com.example.publicart.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; @Data @TableName("artwork") public class Artwork { @TableId(type = IdType.AUTO) private Long id; private String code; private String name; private String artist; private String location; private Integer type; private Integer status; private LocalDate installDate; private String ownerDept; private String createdBy; private LocalDateTime createTime; private LocalDateTime updateTime; @TableLogic private Integer deleted; }@TableName指定表名,@TableId(type = IdType.AUTO)表示主键自增,@TableLogic标记逻辑删除字段。@Data是 Lombok 提供的注解,会自动生成 getter、setter 和 toString 方法。
再创建CostRecord.java:
package com.example.publicart.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @Data @TableName("cost_record") public class CostRecord { @TableId(type = IdType.AUTO) private Long id; private Long artworkId; private Long taskId; private String costType; private BigDecimal amount; private LocalDate occurredDate; private String payDept; private String remark; private LocalDateTime createTime; @TableLogic private Integer deleted; }注意amount使用BigDecimal,这是金额计算的标准做法。
4.4 Mapper 与 Service
创建mapper包:
package com.example.publicart.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.publicart.entity.Artwork; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ArtworkMapper extends BaseMapper<Artwork> { }成本记录的 Mapper 同样写法:
package com.example.publicart.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.publicart.entity.CostRecord; import org.apache.ibatis.annotations.Mapper; @Mapper public interface CostRecordMapper extends BaseMapper<CostRecord> { }Service 层可以直接使用 MyBatis-Plus 提供的IService和ServiceImpl。这里为了简化,我直接在 Controller 中注入 Mapper,实际项目建议还是补一层 Service,方便加事务和业务校验。
4.5 Controller 接口
创建controller包,统一接口前缀为/api。先写一个通用返回对象:
package com.example.publicart.common; import lombok.Data; @Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }然后编写艺术品管理接口:
package com.example.publicart.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.publicart.common.Result; import com.example.publicart.entity.Artwork; import com.example.publicart.mapper.ArtworkMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/artworks") public class ArtworkController { @Autowired private ArtworkMapper artworkMapper; @PostMapping public Result<String> create(@RequestBody Artwork artwork) { artworkMapper.insert(artwork); return Result.ok("新增成功"); } @GetMapping("/page") public Result<Page<Artwork>> page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String name, @RequestParam(required = false) Integer status) { QueryWrapper<Artwork> wrapper = new QueryWrapper<>(); wrapper.lambda() .like(name != null && !name.isEmpty(), Artwork::getName, name) .eq(status != null, Artwork::getStatus, status) .orderByDesc(Artwork::getCreateTime); Page<Artwork> result = artworkMapper.selectPage(new Page<>(page, size), wrapper); return Result.ok(result); } @PutMapping public Result<String> update(@RequestBody Artwork artwork) { artworkMapper.updateById(artwork); return Result.ok("更新成功"); } @DeleteMapping("/{id}") public Result<String> delete(@PathVariable Long id) { Artwork artwork = new Artwork(); artwork.setId(id); artworkMapper.deleteById(artwork); return Result.ok("删除成功"); } }这段代码有几个细节值得说:
- 使用
QueryWrapper动态拼接条件。当name为空时,like条件不会生效;当status为空时,eq条件不会生效。 orderByDesc(Artwork::getCreateTime)让新增作品排在最前面。- 删除操作走的是逻辑删除,数据不会真的消失,这是为了避免误删资产档案。
4.6 成本统计接口
统计是成本管理的核心。下面提供一个按月份统计年度成本的接口:
package com.example.publicart.controller; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.example.publicart.common.Result; import com.example.publicart.entity.CostRecord; import com.example.publicart.mapper.CostRecordMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/statistics") public class CostStatisticsController { @Autowired private CostRecordMapper costRecordMapper; @GetMapping("/cost") public Result<List<Map<String, Object>>> costStatistics(@RequestParam(required = false) Integer year) { Integer targetYear = year != null ? year : LocalDate.now().getYear(); QueryWrapper<CostRecord> wrapper = new QueryWrapper<>(); wrapper.select("DATE_FORMAT(occurred_date, '%Y-%m') AS month", "SUM(amount) AS total") .eq("DATE_FORMAT(occurred_date, '%Y')", targetYear) .groupBy("month") .orderByAsc("month"); List<Map<String, Object>> list = costRecordMapper.selectMaps(wrapper); return Result.ok(list); } }在这个接口里,DATE_FORMAT(occurred_date, '%Y-%m')会把费用日期统一成“2025-01”这样的月份格式,再按月份分组求和。返回结果类似:
{ "code": 200, "message": "success", "data": [ { "month": "2025-01", "total": 3200.00 }, { "month": "2025-02", "total": 4800.00 } ] }需要注意,selectMaps返回的字段名是数据库列别名。MySQL 中如果使用AS month,部分驱动会转成大写MONTH,前端拿到后要做兼容。更稳定的做法是统一在 SQL 中使用小写别名,或者在 Java 里做一次字段名转换。
4.7 跨域与时间格式配置
前端开发服务器默认是http://localhost:3000,后端是http://localhost:8080,两者不同源,所以需要开启跨域访问。新建一个配置类:
package com.example.publicart.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600); } }如果是线上部署,建议把allowedOrigins改为具体的域名,不要长期使用*,否则接口容易被任意网站跨域调用。
5. Vue 3 前端实现
5.1 项目初始化和依赖
前端工程可以用 Vite 快速初始化:
npm create vite@latest public-art-web -- --template vue cd public-art-web npm install npm install axios element-plus echarts安装完成后,在main.js中引入 Element Plus:
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')5.2 封装 axios 请求
在src/api/request.js中创建 axios 实例:
import axios from 'axios' const request = axios.create({ baseURL: 'http://localhost:8080', timeout: 10000 }) request.interceptors.response.use( response => { return response.data }, error => { console.error('接口异常:', error) return Promise.reject(error) } ) export default request然后在src/api/artwork.js中封装接口:
import request from './request' export const getArtworkPage = (params) => { return request.get('/api/artworks/page', { params }) } export const createArtwork = (data) => { return request.post('/api/artworks', data) } export const updateArtwork = (data) => { return request.put('/api/artworks', data) } export const deleteArtwork = (id) => { return request.delete(`/api/artworks/${id}`) } export const getCostStatistics = (params) => { return request.get('/api/statistics/cost', { params }) }5.3 艺术品列表页
在src/views/ArtworkList.vue中,使用el-table和el-pagination展示数据。核心代码片段如下:
<template> <div> <el-form inline> <el-form-item label="作品名称"> <el-input v-model="query.name" placeholder="请输入作品名称" clearable /> </el-form-item> <el-form-item> <el-button type="primary" @click="loadData">查询</el-button> </el-form-item> </el-form> <el-table :data="tableData" border stripe> <el-table-column prop="code" label="资产编号" width="120" /> <el-table-column prop="name" label="作品名称" /> <el-table-column prop="artist" label="作者" width="120" /> <el-table-column prop="location" label="所在位置" /> <el-table-column label="操作" width="150"> <template #default> <el-button type="danger" size="small" @click="handleDelete">删除</el-button> </template> </el-table-column> </el-table> <el-pagination background layout="total, prev, pager, next" :total="total" v-model:current-page="query.page" :page-size="query.size" @current-change="loadData" /> </div> </template> <script setup> import { ref, reactive, onMounted } from 'vue' import { getArtworkPage, deleteArtwork } from '../api/artwork' const tableData = ref([]) const total = ref(0) const query = reactive({ page: 1, size: 10, name: '' }) const loadData = async () => { const res = await getArtworkPage(query) tableData.value = res.data.records total.value = res.data.total } const handleDelete = async (row) => { await deleteArtwork(row.id) loadData() } onMounted(() => { loadData() }) </script>注意代码中handleDelete里的row参数需要从模板作用域传入,这里为了简洁省略了row的传递,实际使用时应该写成@click="handleDelete(row)"。
5.4 成本统计图
成本趋势图使用 ECharts。在src/views/CostStatistics.vue中:
<template> <div> <el-select v-model="year" placeholder="选择年份" @change="loadData"> <el-option label="2024" value="2024" /> <el-option label="2025" value="2025" /> </el-select> <div ref="chartRef" style="height: 400px; margin-top: 16px"></div> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import * as echarts from 'echarts' import { getCostStatistics } from '../api/artwork' const chartRef = ref(null) const year = ref('2025') let chartInstance = null const loadData = async () => { const res = await getCostStatistics({ year: year.value }) const months = res.data.map(item => item.month) const values = res.data.map(item => Number(item.total)) if (chartInstance) { chartInstance.setOption({ xAxis: { data: months }, series: [{ data: values }] }) } } onMounted(() => { chartInstance = echarts.init(chartRef.value) loadData() }) onBeforeUnmount(() => { if (chartInstance) { chartInstance.dispose() } }) </script>这里只展示了核心思路,完整的 ECharts option 还需要配置标题、tooltip、yAxis 等,你可以按需补充。关键是理解“后端按月份聚合结果,前端直接渲染”的数据流。
6. 运行与验证
6.1 启动后端
在后端项目根目录执行:
mvn spring-boot:run看到类似下面的日志说明启动成功:
Started PublicArtApplication in 3.12 seconds Tomcat started on port(s): 8080如果数据库连接失败,优先检查application.yml中的用户名、密码、数据库名是否正确。
6.2 启动前端
在public-art-web目录下执行:
npm run dev默认访问地址是http://localhost:3000。打开页面后,可以先在艺术品列表页新增一条数据,再查看成本统计页。
6.3 接口验证示例
用 curl 测试新增作品接口:
curl -X POST http://localhost:8080/api/artworks \ -H "Content-Type: application/json" \ -d '{ "code": "ART-001", "name": "街头壁画示例", "artist": "Unknown", "location": "城市文化广场", "type": 1, "status": 1, "ownerDept": "城市管理部" }'预期返回:
{ "code": 200, "message": "success", "data": "新增成功" }查询成本统计:
curl "http://localhost:8080/api/statistics/cost?year=2025"如果数据库中还没有成本记录,返回的data会是空数组,这是正常现象。
7. 常见问题与排查思路
在实际开发过程中,新手最常见的问题可以总结成下面这张表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 启动时报数据库连接失败 | 密码错误、数据库未启动、URL 写错 | 先用 Navicat 等工具测试连接,再检查配置 |
| 接口返回 404 | Controller 路径或启动类扫描包不正确 | 确认启动类在根包下,Controller 使用@RestController |
| 时间字段返回乱码 | 数据库字符集不是 utf8mb4 | 建表时指定DEFAULT CHARSET=utf8mb4 |
| 前端请求接口跨域 | 后端未配置跨域 | 添加CorsConfig,允许前端地址访问 |
| 金额统计出现精度问题 | 数据库字段用FLOAT或DOUBLE | 修改为DECIMAL(12, 2),Java 中使用BigDecimal |
| 删除数据后仍然能查到 | 逻辑删除字段配置不正确 | 检查@TableLogic和logic-delete-value配置 |
| 分页查询不生效 | 没有配置 MyBatis-Plus 分页插件 | 添加MybatisPlusInterceptor并注册分页插件 |
如果是使用 Spring Boot 3.x 项目,还要额外注意引入mybatis-plus-spring-boot3-starter,而不是mybatis-plus-boot-starter,依赖坐标不同。
排查接口问题时,建议先把 MyBatis-Plus 的 SQL 日志打开。配置文件里已经设置了log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,启动后在控制台直接观察打印的 SQL 语句,比猜测问题要高效得多。
8. 工程化最佳实践
8.1 资产管理规范
资产编号是系统的唯一索引,建议采用“部门编码 + 年份 + 流水号”的格式,例如WG-2025-001。这样即使后期系统切换,也能通过编号快速确认资产归属。
艺术品状态变化必须留下记录。比如作品从“在展”变为“维护中”,建议额外增加一张状态变更表,记录操作人、变更时间、变更原因。这套系统的artwork表里只有当前状态,长期来看不够完整,实际项目中可以扩展。
8.2 成本数据精度
金额计算必须坚持BigDecimal,不要用double。在 Java 中,0.1 + 0.2的 double 结果并不是精确的0.3,这可能影响成本统计的准确性。
数据库层面,建议在cost_record表上增加一个唯一约束或唯一索引,保证同一笔成本不会重复录入。更严格的方案是在应用层做幂等校验,例如按“艺术品 + 费用类型 + 费用日期 + 金额 + 支付部门”作为业务唯一键。
8.3 权限与审计
如果系统要上线,必须考虑权限控制。至少区分以下角色:
- 管理员:负责资产新增、修改、删除。
- 维护人员:负责工单处理。
- 财务/管理层:只读成本和统计页面。
成本数据属于敏感数据,修改操作应该记录审计日志,包括操作人、操作时间、修改前后内容。技术上可以使用 Spring Security + Sa-Token,也可以先用拦截器做简单的登录鉴权,根据项目复杂度决定。
8.4 数据备份与合规
数据库建议开启定时备份。简单的方式是使用 mysqldump:
mysqldump -u root -p public_art_system > backup_$(date +%Y%m%d).sql生产系统不要直接使用 root 账号连接应用,应单独创建最小权限账号:
CREATE USER 'art_app'@'%' IDENTIFIED BY 'your_strong_password'; GRANT SELECT, INSERT, UPDATE, DELETE ON public_art_system.* TO 'art_app'@'%'; FLUSH PRIVILEGES;这样做即使应用方配置泄露,也不至于让攻击者直接删库或改表结构。
公共艺术品数据如果涉及公开财政支出,发布到线上时还要注意数据脱敏,不要随意关联操作人员身份证、手机号等敏感信息。涉及公共资金数据的展示,建议在显著位置注明数据来源,并对统计口径做清晰说明。
9. 总结与下一步学习路线
通过这个实战项目,我们完成了一套公共艺术品资产与维护成本管理系统的核心闭环。从数据库设计、Spring Boot 后端接口、成本统计,到 Vue 3 前端页面和 ECharts 可视化,整体流程覆盖了前后端分离开发中最常用的一套技术组合。
你学到的关键技术点包括:
- 用 MyBatis-Plus 快速完成单表 CRUD 和分页查询。
- 用
QueryWrapper动态拼接查询条件。 - 用
DATE_FORMAT配合GROUP BY做按月份成本聚合。 - 用逻辑删除避免资产数据误删。
- 用 Vue 3 组合式 API 封装接口并渲染列表和图表。
下一步,你可以沿着这几个方向继续深入:
- 给系统增加登录认证和角色权限,使用 Spring Security 或 Sa-Token。
- 增加“维护工单”的完整流程,支持待办、审批和提醒。
- 使用定时任务,每月自动生成成本统计报表并推送邮件。
- 引入 Redis,缓存统计结果,减少高频查询对数据库的压力。
实际项目中,公共资产的管理还会涉及 GIS 地图定位、图片上传、存证归档等需求。建议先把这个基础版本跑通,再逐步迭代。如果你在做毕业设计或项目实训,也可以以本文为骨架,补充一个具体的业务场景,让系统更有完整性。
如果这篇文章对你有帮助,欢迎收藏备用。遇到问题也可以在评论区留言,我会根据实际经验帮你分析。