1. 项目背景与核心需求
这个基于BS架构的积分制商品交易平台,本质上是一个轻量级的电商系统变体。不同于传统电商用货币结算,它采用积分作为唯一交易媒介,这种模式在会员体系、企业内福利平台、游戏道具商城等场景中非常常见。
我去年为某连锁健身房开发过类似系统,他们的核心需求是:会员通过健身打卡、课程参与等行为累积积分,然后用积分兑换运动装备或私教课程。这种模式既能提升用户粘性,又能消化库存,实现双赢。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与架构设计
2.1 为什么选择Spring Boot
Spring Boot的自动配置特性让Web应用开发变得极其高效。以数据库连接为例,传统Spring需要手动配置DataSource、TransactionManager等bean,而Spring Boot只需要在application.properties中写几行配置:
properties复制spring.datasource.url=jdbc:mysql://localhost:3306/point_mall
spring.datasource.username=root
spring.datasource.password=123456
spring.jpa.hibernate.ddl-auto=update
2.2 BS架构的优势与实现
BS(Browser/Server)架构相比CS架构更适合这类系统,因为:
- 零客户端安装:用户通过浏览器即可访问
- 跨平台:兼容Windows、Mac、手机等设备
- 维护成本低:升级只需更新服务端
前端我推荐Thymeleaf模板引擎而非纯前后端分离,因为:
- 毕设项目规模有限,模板引擎开发效率更高
- 避免了跨域等复杂问题
- 与Spring Boot整合度更好
3. 核心功能模块实现
3.1 用户积分管理
积分变动是系统的核心,需要特别注意并发问题。我采用乐观锁实现:
java复制@Transactional
public boolean deductPoints(Long userId, int points) {
User user = userRepository.findById(userId).orElseThrow();
if (user.getPoints() < points) {
return false;
}
int updated = userRepository.updateUserPoints(
userId,
user.getPoints() - points,
user.getVersion() // 乐观锁版本号
);
return updated > 0;
}
3.2 商品兑换流程
兑换业务逻辑要注意的点:
- 库存检查
- 积分扣减
- 生成订单
- 库存更新
必须用@Transactional保证原子性:
java复制@Transactional(rollbackFor = Exception.class)
public Order exchangeGoods(Long userId, Long goodsId, int quantity) {
// 1. 校验商品库存
Goods goods = goodsRepository.findById(goodsId).orElseThrow();
if (goods.getStock() < quantity) {
throw new BusinessException("库存不足");
}
// 2. 扣减积分
int totalPoints = goods.getPoints() * quantity;
if (!pointService.deductPoints(userId, totalPoints)) {
throw new BusinessException("积分不足");
}
// 3. 扣减库存
goods.setStock(goods.getStock() - quantity);
goodsRepository.save(goods);
// 4. 生成订单
Order order = new Order();
order.setUserId(userId);
order.setGoodsId(goodsId);
order.setQuantity(quantity);
order.setTotalPoints(totalPoints);
return orderRepository.save(order);
}
4. 数据库设计关键点
4.1 核心表结构
sql复制CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(100) NOT NULL,
`points` int DEFAULT '0',
`version` int DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_username` (`username`)
);
CREATE TABLE `goods` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`description` text,
`points` int NOT NULL,
`stock` int NOT NULL,
`image_url` varchar(255),
PRIMARY KEY (`id`)
);
CREATE TABLE `order` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`goods_id` bigint NOT NULL,
`quantity` int NOT NULL,
`total_points` int NOT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
);
4.2 索引优化建议
- 用户表的username需要唯一索引,防止重复注册
- 订单表的user_id需要普通索引,加速用户订单查询
- 商品表的points字段可以考虑索引,如果经常按积分范围查询
5. 典型问题与解决方案
5.1 积分流水记录
很多同学会忽略积分变动记录,导致后期对账困难。建议单独建表:
sql复制CREATE TABLE `point_log` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`change_points` int NOT NULL,
`current_points` int NOT NULL,
`type` varchar(20) NOT NULL COMMENT 'ADD/DEDUCT',
`biz_id` bigint COMMENT '关联业务ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
);
5.2 定时任务设计
比如每日签到送积分功能:
java复制@Scheduled(cron = "0 0 0 * * ?")
public void resetDailySign() {
userRepository.resetAllSignStatus();
}
@Transactional
public boolean dailySign(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
if (user.isTodaySigned()) {
return false;
}
user.setTodaySigned(true);
user.setPoints(user.getPoints() + 10);
userRepository.save(user);
// 记录积分流水
pointLogService.addLog(userId, 10, user.getPoints(), "DAILY_SIGN", null);
return true;
}
6. 界面设计建议
6.1 商品列表页
应该包含:
- 商品图片
- 名称
- 所需积分
- 剩余库存
- 立即兑换按钮
用Thymeleaf实现示例:
html复制<div th:each="goods : ${goodsList}" class="goods-item">
<img th:src="${goods.imageUrl}" width="200">
<h3 th:text="${goods.name}"></h3>
<p>需要积分:<span th:text="${goods.points}"></span></p>
<p>剩余库存:<span th:text="${goods.stock}"></span></p>
<button th:if="${goods.stock > 0}"
th:onclick="'exchange(' + ${goods.id} + ')'">
立即兑换
</button>
<button th:if="${goods.stock <= 0}" disabled>
已售罄
</button>
</div>
6.2 个人中心页
关键信息:
- 当前积分
- 最近积分变动记录
- 已兑换商品列表
7. 安全防护措施
7.1 基础安全配置
在Spring Security配置中至少需要:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeHttpRequests(auth -> auth
.requestMatchers("/register", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login")
.permitAll()
);
return http.build();
}
}
7.2 密码存储
千万不要明文存储密码!使用BCrypt加密:
java复制@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 注册时加密
public User register(String username, String password) {
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
return userRepository.save(user);
}
8. 测试要点
8.1 单元测试示例
测试积分扣减逻辑:
java复制@Test
@Transactional
public void testDeductPoints() {
// 初始化测试用户
User user = new User();
user.setUsername("test");
user.setPassword("123");
user.setPoints(100);
user = userRepository.save(user);
// 测试正常扣减
boolean result = pointService.deductPoints(user.getId(), 50);
assertTrue(result);
// 验证积分已更新
User updated = userRepository.findById(user.getId()).get();
assertEquals(50, updated.getPoints());
// 测试积分不足
result = pointService.deductPoints(user.getId(), 100);
assertFalse(result);
}
8.2 压力测试建议
使用JMeter模拟并发兑换场景,特别注意:
- 库存超卖问题
- 积分重复扣减问题
- 系统响应时间
9. 部署方案
9.1 本地开发环境
推荐使用Docker快速搭建MySQL:
bash复制docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql:8.0
9.2 生产环境建议
- 使用Nginx反向代理
- 启用HTTPS
- 配置数据库主从复制
- 添加应用监控(如Spring Boot Actuator)
10. 项目扩展方向
如果想提升项目档次,可以考虑:
- 引入Redis缓存热门商品
- 添加优惠券系统
- 实现积分过期机制
- 开发管理后台
- 接入微信小程序
我在实际项目中遇到过的一个坑是:没有及时清理过期积分,导致数据库膨胀严重。后来通过添加定时任务解决了:
java复制@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
public void clearExpiredPoints() {
pointLogService.clearExpiredLogs();
userService.adjustExpiredPoints();
}
