
最近帮人搭过一套「Spring Boot Vue 3 Android」三位一体的居民自来水缴费系统趁热把整个项目从设计、选型到落地的完整思路和实操细节整理出来。这套项目其实就是典型的“一个用户端 App 一个运营管理后台 一套后端服务”的组合核心场景就三件事居民用户在水费 App 上查账单、缴水费、看历史记录水务公司的抄表员和管理员在后台维护用户水表信息、录入抄表数据、生成账单并统计分析。项目编号 27413267 那个版本我实际跑通了下面把能直接“抄作业”的部分全部写出来。如果你正在做类似的管理系统毕设或者想在公司内部搭一套轻量级的缴费工具这篇文章可以帮你省掉大量试错时间。我会从整体架构、数据库设计、后端接口、Vue 3 管理端、Android 客户端五个维度完整拆解最后把我在实际开发中踩过的版本兼容性、权限配置、跨域访问这些坑全部列出来。1. 项目整体设计与需求拆解1.1 核心业务闭环从抄表到缴费的完整链路做项目之前一定要先把业务链路画清楚。水务缴费系统的核心链路其实就一条抄表员抄表 → 系统生成账单 → 用户查收账单 → 用户在线缴费 → 系统销账并记录流水。第一步抄表数据从哪里来在实际项目中抄表员会在后台管理端手动录入或者批量导入每户水表的当前读数系统自动用“本次读数 - 上次读数”算出用水量再乘以阶梯水价得到应缴金额。这里有个细节如果用户欠费超过一定期限系统还需要自动生成违约金这个逻辑我后面会细说。第二步账单生成后居民用户打开 Android App 输入户号就能看到本期账单包括用水量、单价、金额、缴费截止日期。缴费环节用“模拟支付”最稳妥——在 App 里调用微信支付或支付宝的 SDK 需要企业资质个人开发者基本走不通。所以这个项目里我设计了一个支付接口的抽象层本地用“余额扣款”或者“扫码模拟支付”来代替业务闭环照样能跑通而且对接真实支付渠道时只需要替换一个实现类。第三步缴费成功后后端事务里同时更新账单状态为“已缴费”、生成缴费流水记录、更新用户账户余额。这三个操作必须在一个事务里完成不然会出现钱扣了账单还是待缴状态的问题。1.2 三类用户角色与管理端权限划分这个系统里有三类角色决定了后台的权限设计必须做到“数据隔离”和“操作范围隔离”。系统管理员负责全局配置包括水价方案维护、片区管理、员工账号分配、缴费统计查看。管理员可以看到所有片区的全量数据。抄表员/客服抄表员只能看到自己负责的片区主要操作是录入读数、批量导入表底数、处理用户换表申请客服则负责处理居民在 App 里提交的故障报修和用水异常申诉。居民用户这是 Android 端的使用者功能范围就限制在绑定户号、查账单、缴费、查历史记录、报修这几个动作上。权限设计上后端我用了Spring Security JWT Redis的组合用户登录成功后签发 JWTRedis 里存一份用户权限缓存每次请求通过拦截器校验 token再从 Redis 拉取权限标识做接口级别的权限控制。Vue 3 管理端则通过动态路由实现菜单级别的权限判断——不同角色登录后看到的侧边栏菜单完全不同这个在管理后台里体验差别很大。2. 技术选型为什么是 Spring Boot Vue 3 Android2.1 后端选型Spring Boot 版本的取舍很重要Spring Boot 是整个项目的地基但版本选择直接决定你后面要踩多少坑。我建议用Spring Boot 2.7.x 搭配 JDK 8/11不要盲目上 Spring Boot 3.x。为什么因为很多教程、网上开源的代码片段都还是基于旧版的如果你选了 3.xSpring Security 6 的配置方式变化非常大SecurityFilterChain的 Bean 定义方式、antMatchers改成requestMatchers这些对新手来说全是暗坑。具体到这套系统后端核心依赖就这几类parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdredis.clients/groupId artifactIdjedis/artifactId version3.7.0/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency /dependenciesORM 我选了 MyBatis Plus理由很简单单表 CRUD 不需要写 XML直接用BaseMapper提供的selectById、insert、updateById就够了。只有联表查询和复杂统计会用到自定义 SQL比如按月份统计各片区应收金额、实收金额这些写 XML 文件管理更清晰。2.2 管理端选型Vue 3 Vite 是当前最优解管理后台用 Vue 3 现在已经是共识了。Vue 3 的 Composition API 配合script setup写起来比 Vue 2 的 Options API 清爽很多Vite 的冷启动速度比 Webpack 快一个量级开发体验舒服得多。如果你之前没接触过 Vue 3直接学 Vue 3 就行Vue 2 已经是维护模式了。项目结构上我推荐用Vite Vue 3 TypeScript Element Plus Pinia Vue Router这套组合这也是目前最主流的全栈后台方案以后找工作写简历也好看。Pinia 是 Vue 3 官方推荐的状态管理库比 Vuex 4 简单很多没有 mutation 那一层直接改 state 就行。管理端最核心的几个页面用户管理维护居民用户基础信息包括户号、姓名、手机号、所在片区、住址、水表编号。抄表管理抄表员按片区筛选用户录入当前水表读数系统自动计算用水量和金额并生成账单。账单管理所有生成的账单列表支持按状态待缴费/已缴费/已逾期筛选可以查看账单详情和缴费流水。统计报表按片区、按月统计应收、实收、欠费率用 ECharts 折线图和柱状图展示趋势。2.3 客户端选型Android 原生和混合开发怎么选Android 端我使用的是原生 Kotlin 开发没有用 Flutter 或 uni-app。原因是这个系统对设备能力没有太多要求就是列表展示、表单提交、网络请求这些基础功功能用原生开发配合 Material Design 组件库体积最小、兼容性最稳定。Kotlin 现在是 Android 官方推荐语言代码比 Java 简洁很多特别是数据类data class和空安全特性解析后端 JSON 时太省事了。Android 端的基本技术栈是Retrofit OkHttp Gson RecyclerView LiveData ViewModelMVVM 架构。网络层用 Retrofit 注解定义接口配合 OkHttp 拦截器统一往请求头里塞 JWT token响应体用 Gson 自动解析成数据类。说句实在话如果你只做毕设或者内部工具用 Kotlin Retrofit 这套最合适。如果要做商业级产品再去考虑 jetpack compose 或者跨平台方案。技术选型永远不要追求最新的要追求团队能驾驭的。3. 数据库设计与核心表结构解析3.1 五张核心表的设计思路这套系统的数据量不大但表关系必须清晰。我按业务链路设计了五张表-- 1. 居民用户表 CREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT, user_no varchar(32) NOT NULL COMMENT 户号登录账号, password varchar(128) NOT NULL COMMENT BCrypt加密密码, real_name varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, address varchar(255) DEFAULT NULL, area_id bigint(20) DEFAULT NULL COMMENT 片区id, meter_no varchar(32) DEFAULT NULL COMMENT 水表编号, last_reading decimal(10,2) DEFAULT 0.00 COMMENT 上次抄表读数, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 2. 抄表记录表 CREATE TABLE t_meter_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, meter_no varchar(32) DEFAULT NULL, current_reading decimal(10,2) NOT NULL COMMENT 本次读数, usage_amount decimal(10,2) DEFAULT NULL COMMENT 用水量, operator_id bigint(20) DEFAULT NULL COMMENT 抄表员id, record_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 3. 账单表 CREATE TABLE t_bill ( id bigint(20) NOT NULL AUTO_INCREMENT, bill_no varchar(32) NOT NULL COMMENT 账单编号, user_id bigint(20) NOT NULL, period varchar(20) NOT NULL COMMENT 账期如2024-01, usage_amount decimal(10,2) DEFAULT NULL, total_amount decimal(10,2) NOT NULL COMMENT 应缴金额, status tinyint(4) DEFAULT 0 COMMENT 0待缴费 1已缴费 2已逾期, create_time datetime DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 4. 缴费流水表 CREATE TABLE t_payment ( id bigint(20) NOT NULL AUTO_INCREMENT, pay_no varchar(64) NOT NULL COMMENT 支付流水号, bill_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, amount decimal(10,2) NOT NULL, pay_method tinyint(4) DEFAULT 0 COMMENT 0模拟支付 1微信 2支付宝, pay_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 5. 水价方案表 CREATE TABLE t_price ( id bigint(20) NOT NULL AUTO_INCREMENT, price_name varchar(50) DEFAULT NULL, step_from decimal(10,2) NOT NULL COMMENT 阶梯起始用量, step_to decimal(10,2) DEFAULT NULL COMMENT 阶梯结束用量, price decimal(10,2) NOT NULL COMMENT 单价, effective_date date DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 生成账单的核心计算逻辑生成账单的算法逻辑是整个系统的核心业务流程是抄表员录入本次读数后系统先计算用水量再根据水价方案表分段计算水费。简单说明一下阶梯水价逻辑假设第一阶梯是 0-20 吨单价 2.8 元第二阶梯是 21-40 吨单价 3.8 元超过 40 吨按 4.8 元。那么某个用户本月用水 45 吨水费就是20×2.8 20×3.8 5×4.8 56 76 24 156元。这个计算我用一个独立方法封装方便后面如果水价调整只需要改方案表不用动代码public BigDecimal calculateAmount(BigDecimal usageAmount) { ListPriceRule rules priceMapper.selectList( new LambdaQueryWrapperPriceRule() .orderByAsc(PriceRule::getStepFrom)); BigDecimal amount BigDecimal.ZERO; for (PriceRule rule : rules) { BigDecimal max rule.getStepTo(); if (max null) { // 最后一个阶梯取超出部分全部按此单价计算 amount amount.add( usageAmount.subtract(rule.getStepFrom()).multiply(rule.getPrice())); } else { BigDecimal range max.subtract(rule.getStepFrom()); if (usageAmount.compareTo(max) 0) { amount amount.add(range.multiply(rule.getPrice())); } else if (usageAmount.compareTo(rule.getStepFrom()) 0) { amount amount.add( usageAmount.subtract(rule.getStepFrom()).multiply(rule.getPrice())); } } } return amount.setScale(2, RoundingMode.HALF_UP); }这里有一个关键细节金额计算必须用 BigDecimal绝对不能用 double 或 float。水费计费精确到分double 的二进制浮点误差会导致金额无限循环小数或者四舍五入错误这在财务场景里是不可接受的。4. 后端接口设计与关键实现4.1 接口清单与 RESTful 规范后端接口按资源维度规划全部遵循 RESTful 风格模块方法路径说明认证POST/api/auth/login登录返回 JWT token用户GET/api/user/info获取当前用户信息账单GET/api/bill/list分页查询账单列表账单GET/api/bill/detail/{id}账单详情缴费POST/api/payment/pay提交缴费缴费GET/api/payment/records缴费历史记录抄表POST/api/meter/record录入抄表数据统计GET/api/statistics/area按片区统计缴费数据统一返回体我用了一个自定义泛型类Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(success); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }4.2 JWT Redis 双 Token 方案登录态的保持我用了双 Token 方案用户登录成功后返回一个 accessToken有效期 2 小时用于正常请求鉴权和一个 refreshToken有效期 7 天用于刷新 accessToken。为什么要做双 Token因为如果只有一个长期 token一旦泄露别人能在七天之内一直盗用你的身份。分开之后即使 accessToken 泄露也就是 2 小时的有效期而且可以在 Redis 里针对某个用户强制下线。核心代码贴出来Service public class AuthService { Autowired private StringRedisTemplate redisTemplate; public LoginResponse login(String userNo, String password) { User user userMapper.selectOne( new LambdaQueryWrapperUser() .eq(User::getUserNo, userNo)); if (user null || !BCrypt.checkpw(password, user.getPassword())) { throw new RuntimeException(用户名或密码错误); } // 生成 accessToken String accessToken Jwts.builder() .setSubject(user.getId().toString()) .claim(role, USER) .setExpiration(new Date(System.currentTimeMillis() 7200000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); // 生成 refreshToken String refreshToken UUID.randomUUID().toString(); // Redis 存储key 为 userId:tokenTypevalue 为 token redisTemplate.opsForValue().set( auth:token: user.getId(), accessToken, 2, TimeUnit.HOURS); redisTemplate.opsForValue().set( auth:refresh: user.getId(), refreshToken, 7, TimeUnit.DAYS); return new LoginResponse(accessToken, refreshToken); } }4.3 缴费对账用事务保证扣款和销账一致缴费接口是整个系统最需要谨慎的地方。我设计了一个支付服务接口然后用一个“模拟支付”实现类public interface PaymentService { PaymentResult pay(PaymentRequest request); } Service public class MockPaymentServiceImpl implements PaymentService { Override Transactional(rollbackFor Exception.class) public PaymentResult pay(PaymentRequest request) { // 1. 校验账单状态 Bill bill billMapper.selectById(request.getBillId()); if (bill.getStatus() ! 0) { throw new RuntimeException(账单状态不是待缴费状态); } // 2. 生成缴费流水 Payment payment new Payment(); payment.setPayNo(generatePayNo()); payment.setBillId(bill.getId()); payment.setUserId(bill.getUserId()); payment.setAmount(bill.getTotalAmount()); payment.setPayMethod(0); paymentMapper.insert(payment); // 3. 更新账单状态 bill.setStatus(1); bill.setPayTime(new Date()); billMapper.updateById(bill); return new PaymentResult(payment.getPayNo(), bill.getTotalAmount()); } }Transactional注解是关键一旦第 2 步生成流水成功但第 3 步更新账单失败整个事务回滚不会出现流水有记录但账单仍旧未缴的状态。这就是我前面说的“事务保证一致性”。这里再说一个细节generatePayNo()生成支付流水号时我用了“日期前缀 用户ID 随机数”的模式比如20250115 1001 4829。不要直接使用 MySQL 自增 id 作为对外展示的支付流水号一是容易被猜到业务量二是并发上去之后不好看。类似票据这种东西一定要保证全局唯一且无规律可循。5. Vue 3 后台管理端开发实操5.1 工程初始化与路由权限控制Vue 3 管理端我用的工程结构是这样的vue3-water-admin/ ├── src/ │ ├── api/ # 接口请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── layout/ # 后台布局 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态管理 │ ├── views/ # 页面组件 │ ├── utils/ # 工具函数 │ ├── App.vue │ └── main.ts初始化项目直接用 Vite 脚手架npm create vitelatest vue3-water-admin -- --template vue-ts cd vue3-water-admin npm install element-plus pinia vue-router axios npm install element-plus/icons-vue路由权限这块我用的是动态路由方案登录时后端返回当前用户的角色和权限标识前端根据权限标识过滤出该角色可见的路由表再通过router.addRoute()动态添加。而不是在静态路由表里写meta.roles然后靠前端做跳转拦截——因为前端的权限控制属于“体验优化”真正的安全防线一定要在后端接口校验。// router/index.ts 核心逻辑 const constantRoutes: RouteRecordRaw[] [ { path: /login, component: () import(/views/login/index.vue) }, { path: /, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue) } ] } ]; // 动态路由在登录后按权限添加 function generateRoutes(permissions: string[]) { const routes [] as RouteRecordRaw[]; if (permissions.includes(admin)) { routes.push({ path: /user, component: () import(/layout/index.vue), children: [{ path: , name: UserManage, component: () import(/views/user/index.vue) }] }); routes.push({ path: /bill, component: () import(/layout/index.vue), children: [{ path: , name: BillManage, component: () import(/views/bill/index.vue) }] }); } return routes; }5.2 账单管理页面的核心实现账单管理页面需要支持分页查询、按状态筛选、查看详情三个能力。我直接基于 Element Plus 的el-table和el-pagination组件写script setup langts import { ref, onMounted } from vue; import { getBillList } from /api/bill; import { ElMessage } from element-plus; const billList ref([]); const total ref(0); const queryParams ref({ pageNum: 1, pageSize: 10, status: null as number | null }); const statusMap: Recordnumber, string { 0: 待缴费, 1: 已缴费, 2: 已逾期 }; async function fetchBillList() { const { data } await getBillList(queryParams.value); billList.value data.records; total.value data.total; } function handleStatusChange() { queryParams.value.pageNum 1; fetchBillList(); } function handleViewDetail(row: any) { // 跳转详情或者弹窗展示这里用 ElMessageBox ElMessageBox.alert( 账单编号${row.billNo}br/缴费金额${row.totalAmount}br/状态${statusMap[row.status]}, 账单详情, { dangerouslyUseHTMLString: true } ); } onMounted(() { fetchBillList(); }); /script开发这个页面的时候有个小坑el-table的data属性绑定新数组时如果直接把records赋值过去表格有时候不刷新。需要用const newList [...records]; billList.value newList;这种方式触发 Vue 3 的响应式更新。这其实是 Vue 3 响应式系统的一个常见注意点——修改数组长度或者索引位置直接赋值时不会触发更新。5.3 统计报表的 ECharts 大屏展示系统首页我放了“月度缴费趋势折线图”和“片区应收/实收对比柱状图”。数据接口后端已经统计好了前端直接用 ECharts 渲染就行import * as echarts from echarts; const chartRef refHTMLDivElement(); const myChart refecharts.ECharts(); function initChart() { myChart.value echarts.init(chartRef.value!); getMonthlyStatistics().then(({ data }) { myChart.value!.setOption({ xAxis: { type: category, data: data.months }, yAxis: { type: value }, series: [{ name: 应收金额, type: line, smooth: true, data: data.receivableAmounts, areaStyle: { opacity: 0.2 } }, { name: 实收金额, type: line, smooth: true, data: data.receivedAmounts, areaStyle: { opacity: 0.2 } }] }); }); } window.addEventListener(resize, () { myChart.value?.resize(); });统计图表这个需求一定要提醒一句后端传过来的数据字段名要和前端对齐宁可 JSON 里多几个没用字段也不要少字段。前后端联调时项目进度耽误最常见的就是在这儿——后端把字段名从amount改成total_amount前端没同步改图就白屏了。6. Android 居民端 App 开发要点6.1 网络层封装与登录态管理Android 端的网络层我把 Retrofit 封装成一个单例对象集中管理 baseUrl、OkHttp 拦截器、Gson 转换器object NetworkClient { private const val BASE_URL http://10.0.2.2:8080/api/ private val okHttpClient OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .addInterceptor { chain - val original chain.request() val token TokenManager.getToken() val requestBuilder original.newBuilder() if (token.isNotEmpty()) { requestBuilder.header(Authorization, Bearer $token) } chain.proceed(requestBuilder.build()) } .build() val apiService: ApiService Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiService::class.java) }这里有一个特别重要的坑Android 模拟器访问本机后端的地址不是localhost而是10.0.2.2。如果你用的是真机调试后端地址就要换成电脑在局域网里的 IP比如http://192.168.1.100:8080/。这个配置当时我在项目里写了两个环境build.gradle 里用buildConfigField区分 debug 和 release 环境调试起来非常方便。接口定义用 Retrofit 注解interface ApiService { POST(auth/login) suspend fun login(Body request: LoginRequest): ResultLoginResponse GET(bill/list) suspend fun getBillList( Query(pageNum) pageNum: Int, Query(pageSize) pageSize: Int, Query(status) status: Int? ): ResultPageResultBill POST(payment/pay) suspend fun pay(Body request: PayRequest): ResultPaymentResult }6.2 账单列表与缴费流程实现账单列表页我用 RecyclerView ListAdapter配合 ViewBinding 写列表项布局。这里建议直接用 ListAdapter 而不是传统 Adapter因为 ListAdapter 自带 DiffUtil 差量更新逻辑账单缴费之后刷新列表时只有状态变更的那一行会重建其他行复用不会闪烁class BillAdapter : ListAdapterBill, BillAdapter.BillViewHolder(DiffCallback) { var onPayClick: ((Bill) - Unit)? null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BillViewHolder { val binding ItemBillBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BillViewHolder(binding) } override fun onBindViewHolder(holder: BillViewHolder, position: Int) { val bill getItem(position) holder.binding.tvPeriod.text bill.period holder.binding.tvAmount.text ¥${bill.totalAmount} holder.binding.tvStatus.text when (bill.status) { 0 - 待缴费 1 - 已缴费 else - 已逾期 } holder.binding.btnPay.visibility if (bill.status 0) View.VISIBLE else View.GONE holder.binding.btnPay.setOnClickListener { onPayClick?.invoke(bill) } } companion object DiffCallback : DiffUtil.ItemCallbackBill() { override fun areItemsTheSame(oldItem: Bill, newItem: Bill): Boolean oldItem.id newItem.id override fun areContentsTheSame(oldItem: Bill, newItem: Bill): Boolean oldItem newItem } }缴费操作点击后弹出确认对话框点击确认就调用支付接口。如果接口返回成功就刷新当前页的数据并 Toast 提示private fun handlePay(bill: Bill) { lifecycleScope.launch { val request PayRequest(billId bill.id) try { val result NetworkClient.apiService.pay(request) if (result.code 200) { Toast.makeText(thisBillListActivity, 缴费成功, Toast.LENGTH_SHORT).show() currentAdapter.submitList(currentAdapter.currentList.map { if (it.id bill.id) it.copy(status 1, payTime Date()) else it }) } else { Toast.makeText(thisBillListActivity, result.msg, Toast.LENGTH_SHORT).show() } } catch (e: Exception) { Toast.makeText(thisBillListActivity, 网络错误: ${e.message}, Toast.LENGTH_SHORT).show() } } }7. 常见问题排查与避坑记录7.1 后端高频报错与解决方案整理一份我在实际开发中遇到的报错清单每一条都是真实踩坑记录报错/异常原因解决方案Whitelabel Error Page接口 404通常是没有对应的 Mapper 方法或路由不对检查 Controller 的 RequestMapping 路径和前端请求是否一致Invalid bound statementMyBatis XML 文件没扫描到检查MapperScan路径和 XML 文件的namespace是否匹配UnsatisfiedDependencyExceptionService 层循环依赖或者 Bean 注入失败检查字段名是否一致IDEA 用-parameters编译参数CORS 跨域报错Vue 前端和后端端口不一致后端加全局 CORS 配置类或者用 Vite proxy 代理Field id doesnt have a default value用户表密码等字段没填值直接 insert检查实体类字段和数据库字段是否对应确保 NOT NULL 字段有值CORS 配置这个最常用直接写一个 Config 类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }7.2 Vue 3 前端常见问题Element Plus 图标不显示需要全局注册图标组件按需引入的话要在 main.ts 里import * as ElementPlusIconsVue from element-plus/icons-vue然后app.component()注册。axios 报ERR_NETWORK大概率是后端没起或者跨域没配置。先用浏览器直接访问接口看能否通再排查代理配置。npm 安装依赖特别慢建议用淘宝镜像源npm config set registry https://registry.npmmirror.com。这个非常实用不然一个 Vue3 项目npm install卡半天。打包后路由直接刷新 404这是 history 路由模式的典型问题部署到 Nginx 需要配置try_files $uri $uri/ /index.html;指向 index.html刷新才不会 404。7.3 Android 端常见坑Android 9 以上默认禁止 HTTP 明文流量后端如果是http://接口Android 端需要在AndroidManifest.xml的application标签里加android:usesCleartextTraffictrue否则请求直接失败。Retrofit suspend 函数报错确认kotlinx-coroutines-android依赖装了且接口定义在 Kotlin 文件里Java 代码里不能直接用 suspend 函数。RecyclerView 列表数据更新不生效检查是否调用了notifyDataSetChanged()或者 ListAdapter 的submitList()并且在主线程中执行。8. 项目扩展与后续优化建议这套系统跑通之后扩展方向其实非常清晰。如果时间充裕我建议你可以往这几个方向加功能短信通知模块接入阿里云或腾讯云的短信服务账单生成后自动给用户手机发短信提醒或者 App 内集成 UniPush 推送服务。这个功能在毕设答辩时加分会非常明显因为它在业务上“解决了一个实际问题”。支付渠道对接我说过项目里用的是模拟支付对接微信支付Native 扫码支付其实不需要营业执照只需要一个已认证的微信商户号。把PaymentService接口新增一个WechatPaymentServiceImpl实现类替换Service注解即可不影响现有代码。多级角色权限增强目前只有管理员、抄表员、用户三个角色。如果你拿到市级的项目可能有区域经理、财务、稽查等多个角色这时可以用 RBAC 模型做完整的用户-角色-权限三表设计。大数据量下的报表优化当用户量超过 10 万账单数据超过百万条时按月统计的 SQL 就会变慢。可以考虑用定时任务XXL-Job 或 Spring 自带 Scheduled每天凌晨跑一次汇总表把统计结果提前算好存到一张报表表里页面查询直接查汇总表速度会快很多。我在实际做这个项目时最深的一个体会是这类管理系统真正的难点从来不在于某个技术点有多深而在于版本组合、环境配置、前后端联调这些细碎的问题太消耗时间了。所以如果你准备照着这个思路复现或者正在做类似的毕设项目我建议你提前把环境统一好后端 JDK 8 Spring Boot 2.7前端 Node 16 以上 ViteAndroid 端 compileSdk 33 以上配套 Retrofit 2.9整体跑通一次之后后面就顺了。最后再分享一个小技巧接口联调阶段别依赖 Swagger 或 Postman 手动点点点直接写一套 Postman 的 Collection 集合文件把登录、查账单、缴费、统计这些接口按顺序排好设置好变量引用登录接口返回的 token 自动赋给后续接口的 Header效率会翻一倍。这是我试过最稳的联调方式。