校园卡服务管理系统是高校信息化建设的重要组成部分,它直接关系到师生的日常学习和生活。作为一个完整的JavaWeb项目,这个系统需要处理校园卡的发卡、充值、消费、挂失、补办等全生命周期管理,同时还要与学校的财务系统、门禁系统、图书管理系统等进行数据交互。
我在实际开发过程中发现,一个健壮的校园卡系统需要特别注意并发交易处理和数据一致性。比如在食堂高峰期,系统需要同时处理数百笔消费请求,这对数据库设计和事务管理提出了很高要求。下面我将从系统设计到实现细节,分享这个项目的完整开发经验。
后端采用SpringBoot+MyBatis框架组合:
前端采用主流技术栈:
数据库选用MySQL 8.0:
提示:校园卡系统对事务要求严格,建议在MySQL配置中设置innodb_flush_log_at_trx_commit=1,确保每次事务都持久化到磁盘。
用户管理模块
卡片管理模块
交易处理模块
财务对账模块
系统管理模块
消费交易的核心代码实现:
java复制@Transactional(isolation = Isolation.REPEATABLE_READ)
public Result deductBalance(String cardId, BigDecimal amount) {
// 1. 检查卡片状态
Card card = cardMapper.selectById(cardId);
if(card == null || card.getStatus() != 1) {
return Result.error("卡片无效或已挂失");
}
// 2. 检查余额是否充足
if(card.getBalance().compareTo(amount) < 0) {
return Result.error("余额不足");
}
// 3. 扣减余额
cardMapper.updateBalance(cardId, amount.negate());
// 4. 记录交易流水
Transaction transaction = new Transaction();
transaction.setCardId(cardId);
transaction.setAmount(amount);
transaction.setType(2); // 消费类型
transactionMapper.insert(transaction);
// 5. 更新Redis缓存
redisTemplate.opsForValue().decrement(
"card:balance:"+cardId,
amount.doubleValue()
);
return Result.success();
}
批量制卡时采用多线程处理:
java复制public void batchIssueCards(List<CardInfo> cardList) {
// 使用线程池提高处理效率
ExecutorService executor = Executors.newFixedThreadPool(5);
for(CardInfo cardInfo : cardList) {
executor.execute(() -> {
try {
// 1. 生成物理卡
String physicalId = cardProducer.generateCard();
// 2. 初始化数据库记录
Card card = new Card();
card.setPhysicalId(physicalId);
card.setUserId(cardInfo.getUserId());
card.setBalance(BigDecimal.ZERO);
cardMapper.insert(card);
// 3. 激活卡片
cardService.activateCard(physicalId);
} catch (Exception e) {
log.error("制卡失败: {}", cardInfo, e);
}
});
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.HOURS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
卡片表(t_card)
sql复制CREATE TABLE `t_card` (
`id` varchar(20) NOT NULL COMMENT '逻辑卡号',
`physical_id` varchar(20) NOT NULL COMMENT '物理卡号',
`user_id` varchar(20) NOT NULL COMMENT '持卡人ID',
`balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '余额',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态(0-未激活,1-正常,2-挂失,3-注销)',
`issue_date` datetime NOT NULL COMMENT '发卡日期',
`expire_date` datetime DEFAULT NULL COMMENT '失效日期',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_physical_id` (`physical_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
交易流水表(t_transaction)
sql复制CREATE TABLE `t_transaction` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`card_id` varchar(20) NOT NULL COMMENT '卡号',
`amount` decimal(10,2) NOT NULL COMMENT '交易金额',
`type` tinyint(1) NOT NULL COMMENT '类型(1-充值,2-消费)',
`terminal_id` varchar(20) DEFAULT NULL COMMENT '终端设备ID',
`location` varchar(100) DEFAULT NULL COMMENT '交易地点',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_card_id` (`card_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
交易流水表按日期范围查询频繁,建议按月分表:
sql复制CREATE TABLE t_transaction_202301 (
...
) ENGINE=InnoDB;
为高频查询字段添加复合索引:
sql复制ALTER TABLE t_transaction ADD INDEX idx_card_time (card_id, create_time);
余额查询使用覆盖索引:
sql复制ALTER TABLE t_card ADD INDEX idx_balance (id, balance);
消费限额控制:
交易风控规则:
java复制public void checkTransactionRisk(Transaction transaction) {
// 检查短时间内连续交易
int recentCount = transactionMapper.countRecentTransactions(
transaction.getCardId(),
5, // 5分钟内
transaction.getCreateTime()
);
if(recentCount > 10) {
throw new RuntimeException("交易过于频繁");
}
// 检查异常地点变动
String lastLocation = transactionMapper.getLastLocation(
transaction.getCardId()
);
if(lastLocation != null &&
!lastLocation.equals(transaction.getLocation())) {
// 发送预警通知
alertService.sendLocationAlert(
transaction.getCardId(),
lastLocation,
transaction.getLocation()
);
}
}
敏感信息加密存储:
java复制// AES加密工具类
public class AESUtils {
private static final String KEY = "校园卡系统密钥";
public static String encrypt(String data) {
// 实现AES加密逻辑
}
public static String decrypt(String encrypted) {
// 实现AES解密逻辑
}
}
数据库连接加密:
properties复制# application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/card_db?useSSL=true&requireSSL=true
spring.datasource.tomcat.ssl.enabled=true
推荐服务器配置:
采用Nginx+多Tomcat实例的负载均衡方案:
code复制upstream card_server {
server 192.168.1.101:8080 weight=1;
server 192.168.1.102:8080 weight=1;
keepalive 32;
}
server {
listen 80;
server_name card.university.edu;
location / {
proxy_pass http://card_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
MySQL主从复制配置:
ini复制# 主库my.cnf
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW
# 从库my.cnf
[mysqld]
server-id=2
relay-log=mysql-relay-bin
read-only=1
现象:数据库余额与Redis缓存不一致
解决方案:
java复制public BigDecimal getBalance(String cardId) {
// 先从缓存获取
Object balance = redisTemplate.opsForValue().get("card:balance:"+cardId);
if(balance != null) {
return new BigDecimal(balance.toString());
}
// 缓存不存在则查数据库
Card card = cardMapper.selectById(cardId);
if(card != null) {
// 回填缓存
redisTemplate.opsForValue().set(
"card:balance:"+cardId,
card.getBalance().toString(),
1, TimeUnit.HOURS
);
return card.getBalance();
}
throw new RuntimeException("卡片不存在");
}
java复制@Scheduled(cron = "0 3 * * * ?") // 每天凌晨3点执行
public void syncAllBalances() {
List<Card> cards = cardMapper.selectAll();
for(Card card : cards) {
redisTemplate.opsForValue().set(
"card:balance:"+card.getId(),
card.getBalance().toString(),
24, TimeUnit.HOURS
);
}
}
现象:多人同时为同一张卡充值可能导致余额错误
解决方案:
java复制@Transactional
public Result recharge(String cardId, BigDecimal amount) {
// 先查询当前版本号
Card card = cardMapper.selectById(cardId);
// 更新带版本检查
int rows = cardMapper.updateBalanceWithVersion(
cardId,
amount,
card.getVersion()
);
if(rows == 0) {
throw new OptimisticLockingFailureException("充值失败,请重试");
}
// 记录交易流水...
return Result.success();
}
sql复制UPDATE t_card
SET balance = balance + #{amount}
WHERE id = #{cardId}
移动端对接:
数据分析功能:
多系统集成:
在实际部署时,建议先在小范围试运行,重点测试并发交易场景下的数据一致性。我们最初上线时就遇到过高峰期余额不同步的问题,后来通过引入Redis事务和双重检查机制解决了这个问题。