From bebf9c9c507dcc7ec7c709a272db71a7ca523a4e Mon Sep 17 00:00:00 2001 From: zk Date: Thu, 12 Mar 2026 16:40:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jiayunet/aop/FuncPermissionAspect.java | 11 +- .../jiayunet/server/FuncPermissionServer.java | 116 +++++++++++++----- .../mapper/UserFuncUsageLogMapper.java | 14 +++ .../org/jiayunet/pojo/po/FuncPermission.java | 5 + .../jiayunet/pojo/po/UserFuncUsageLog.java | 36 ++++++ 项目结构说明.md | 30 +++-- 6 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 manager/src/main/java/org/jiayunet/mapper/UserFuncUsageLogMapper.java create mode 100644 manager/src/main/java/org/jiayunet/pojo/po/UserFuncUsageLog.java diff --git a/client-api/src/main/java/org/jiayunet/aop/FuncPermissionAspect.java b/client-api/src/main/java/org/jiayunet/aop/FuncPermissionAspect.java index ff2468c..ee52652 100644 --- a/client-api/src/main/java/org/jiayunet/aop/FuncPermissionAspect.java +++ b/client-api/src/main/java/org/jiayunet/aop/FuncPermissionAspect.java @@ -12,7 +12,7 @@ import org.springframework.stereotype.Component; /** * 功能权限校验切面 - *

拦截 @FuncPermission 注解,校验权限并扣减库存后放行

+ *

拦截 @FuncPermission 注解,校验权限并扣减库存后放行,业务异常时自动回退

* * @author zk */ @@ -32,14 +32,15 @@ public class FuncPermissionAspect { log.info("功能权限校验 userId:{} funcCode:{}", userId, funcCode); - // 校验权限 + 扣减库存 - funcPermissionServer.checkAndDeduct(userId, funcCode); + // 校验权限 + 扣减库存,返回使用记录ID + Long logId = funcPermissionServer.checkAndDeduct(userId, funcCode); - // 放行,业务异常时回退库存 + // 放行,业务异常时回退使用记录和库存 try { return joinPoint.proceed(); } catch (Exception e) { - funcPermissionServer.rollbackCount(userId, funcCode); + log.warn("业务异常,回退使用记录 logId:{} userId:{} funcCode:{}", logId, userId, funcCode); + funcPermissionServer.rollbackUsage(logId, userId, funcCode); throw e; } } diff --git a/client-api/src/main/java/org/jiayunet/server/FuncPermissionServer.java b/client-api/src/main/java/org/jiayunet/server/FuncPermissionServer.java index 77d5fce..f7ad3e7 100644 --- a/client-api/src/main/java/org/jiayunet/server/FuncPermissionServer.java +++ b/client-api/src/main/java/org/jiayunet/server/FuncPermissionServer.java @@ -5,15 +5,21 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import lombok.extern.slf4j.Slf4j; import org.jiayunet.exception.BusinessException; import org.jiayunet.exception.BusinessExpCodeEnum; +import org.jiayunet.mapper.FuncPermissionMapper; import org.jiayunet.mapper.UserFuncPermissionStockMapper; +import org.jiayunet.mapper.UserFuncUsageLogMapper; +import org.jiayunet.pojo.po.FuncPermission; import org.jiayunet.pojo.po.UserFuncPermissionStock; +import org.jiayunet.pojo.po.UserFuncUsageLog; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; /** - * 功能权限服务(校验、扣减、查询、添加库存) + * 功能权限服务(校验、扣减、查询、添加库存、回退) * * @author zk */ @@ -21,39 +27,72 @@ import java.time.Instant; @Slf4j public class FuncPermissionServer { + @Autowired + private FuncPermissionMapper funcPermissionMapper; + @Autowired private UserFuncPermissionStockMapper userFuncPermissionStockMapper; + @Autowired + private UserFuncUsageLogMapper userFuncUsageLogMapper; + /** * 校验用户功能权限并扣减库存 + *

优先使用每日免费额度,免费额度用完后走付费库存

* * @param userId 用户ID * @param funcCode 功能权限编码 + * @return 使用记录ID(用于异常回退) */ - public void checkAndDeduct(Long userId, String funcCode) { - // 查询用户功能权限库存 + public Long checkAndDeduct(Long userId, String funcCode) { + // 1. 查功能权限定义 + FuncPermission funcPermission = funcPermissionMapper.selectOne( + new LambdaQueryWrapper() + .eq(FuncPermission::getFuncCode, funcCode) + .eq(FuncPermission::getStatus, 1) + ); + if (funcPermission == null) { + throw new BusinessException(BusinessExpCodeEnum.PERMISSION_DENIED, "功能不存在或未启用"); + } + + // 2. 判断每日免费额度 + int dailyFreeCount = funcPermission.getDailyFreeCount() == null ? 0 : funcPermission.getDailyFreeCount(); + if (dailyFreeCount > 0) { + Instant todayStart = LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant(); + Long todayUsed = userFuncUsageLogMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserFuncUsageLog::getUserId, userId) + .eq(UserFuncUsageLog::getFuncCode, funcCode) + .ge(UserFuncUsageLog::getCreateTime, todayStart) + ); + if (todayUsed < dailyFreeCount) { + // 免费额度未用完,插入使用记录,直接放行 + return insertUsageLog(userId, funcCode); + } + } + + // 3. 免费额度用完或无免费额度,查付费库存 UserFuncPermissionStock stock = userFuncPermissionStockMapper.selectOne( new LambdaQueryWrapper() .eq(UserFuncPermissionStock::getUserId, userId) .eq(UserFuncPermissionStock::getFuncCode, funcCode) ); - - // 无记录,无权限 if (stock == null) { throw new BusinessException(BusinessExpCodeEnum.PERMISSION_DENIED, "无该功能权限"); } - // 时间维度校验 + // 4. 时间维度校验 if (stock.getTimeLimit() == 1 && stock.getExpireTime() != null && stock.getExpireTime().isBefore(Instant.now())) { throw new BusinessException(BusinessExpCodeEnum.PERMISSION_DENIED, "功能权限已过期"); } - // 不限次,直接放行 + // 5. 次数维度校验 if (stock.getCountLimit() == 0) { - return; + // 不限次,直接放行 + return insertUsageLog(userId, funcCode); } - // 限次,扣减库存(乐观锁,SQL原子扣减) + // 限次,SQL原子扣减 int rows = userFuncPermissionStockMapper.update(null, new LambdaUpdateWrapper() .setSql("remain_count = remain_count - 1") @@ -61,10 +100,48 @@ public class FuncPermissionServer { .eq(UserFuncPermissionStock::getFuncCode, funcCode) .gt(UserFuncPermissionStock::getRemainCount, 0) ); - if (rows == 0) { throw new BusinessException(BusinessExpCodeEnum.PERMISSION_DENIED, "功能使用次数已用完"); } + + return insertUsageLog(userId, funcCode); + } + + /** + * 插入使用记录 + * + * @param userId 用户ID + * @param funcCode 功能编码 + * @return 记录ID + */ + private Long insertUsageLog(Long userId, String funcCode) { + UserFuncUsageLog usageLog = new UserFuncUsageLog(); + usageLog.setUserId(userId); + usageLog.setFuncCode(funcCode); + userFuncUsageLogMapper.insert(usageLog); + return usageLog.getId(); + } + + /** + * 回退使用记录(业务异常时调用) + *

删除使用记录,并尝试回退付费库存次数(SQL条件自动过滤)

+ * + * @param logId 使用记录ID + * @param userId 用户ID + * @param funcCode 功能编码 + */ + public void rollbackUsage(Long logId, Long userId, String funcCode) { + // 删除使用记录 + userFuncUsageLogMapper.deleteById(logId); + + // 尝试回退库存次数(count_limit=1才会匹配,其他情况update 0行无影响) + userFuncPermissionStockMapper.update(null, + new LambdaUpdateWrapper() + .setSql("remain_count = remain_count + 1") + .eq(UserFuncPermissionStock::getUserId, userId) + .eq(UserFuncPermissionStock::getFuncCode, funcCode) + .eq(UserFuncPermissionStock::getCountLimit, 1) + ); } /** @@ -94,7 +171,6 @@ public class FuncPermissionServer { UserFuncPermissionStock existing = query(userId, funcCode); if (existing == null) { - // 无记录,新增 UserFuncPermissionStock stock = new UserFuncPermissionStock(); stock.setUserId(userId); stock.setFuncCode(funcCode); @@ -138,7 +214,6 @@ public class FuncPermissionServer { UserFuncPermissionStock existing = query(userId, funcCode); if (existing == null) { - // 无记录,新增 UserFuncPermissionStock stock = new UserFuncPermissionStock(); stock.setUserId(userId); stock.setFuncCode(funcCode); @@ -168,21 +243,4 @@ public class FuncPermissionServer { userFuncPermissionStockMapper.update(null, wrapper); } - - /** - * 回退次数库存(业务异常时调用,仅限次维度有效) - * - * @param userId 用户ID - * @param funcCode 功能权限编码 - */ - public void rollbackCount(Long userId, String funcCode) { - userFuncPermissionStockMapper.update(null, - new LambdaUpdateWrapper() - .setSql("remain_count = remain_count + 1") - .eq(UserFuncPermissionStock::getUserId, userId) - .eq(UserFuncPermissionStock::getFuncCode, funcCode) - .eq(UserFuncPermissionStock::getCountLimit, 1) - ); - } - } diff --git a/manager/src/main/java/org/jiayunet/mapper/UserFuncUsageLogMapper.java b/manager/src/main/java/org/jiayunet/mapper/UserFuncUsageLogMapper.java new file mode 100644 index 0000000..703c02d --- /dev/null +++ b/manager/src/main/java/org/jiayunet/mapper/UserFuncUsageLogMapper.java @@ -0,0 +1,14 @@ +package org.jiayunet.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.jiayunet.pojo.po.UserFuncUsageLog; + +/** + * 用户功能使用记录Mapper + * + * @author zk + */ +@Mapper +public interface UserFuncUsageLogMapper extends CommonMapper { + +} diff --git a/manager/src/main/java/org/jiayunet/pojo/po/FuncPermission.java b/manager/src/main/java/org/jiayunet/pojo/po/FuncPermission.java index 3570f56..00a1dba 100644 --- a/manager/src/main/java/org/jiayunet/pojo/po/FuncPermission.java +++ b/manager/src/main/java/org/jiayunet/pojo/po/FuncPermission.java @@ -30,6 +30,11 @@ public class FuncPermission { */ private String funcName; + /** + * 每日免费使用次数,0表示无免费额度 + */ + private Integer dailyFreeCount; + /** * 状态 1=启用 0=禁用 */ diff --git a/manager/src/main/java/org/jiayunet/pojo/po/UserFuncUsageLog.java b/manager/src/main/java/org/jiayunet/pojo/po/UserFuncUsageLog.java new file mode 100644 index 0000000..0dda298 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/pojo/po/UserFuncUsageLog.java @@ -0,0 +1,36 @@ +package org.jiayunet.pojo.po; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.Instant; + +/** + * 用户功能使用记录表 + * + * @author zk + */ +@Data +@TableName(value = "bg_user_func_usage_log") +public class UserFuncUsageLog { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 功能编码 + */ + private String funcCode; + + /** + * 使用时间 + */ + private Instant createTime; +} diff --git a/项目结构说明.md b/项目结构说明.md index 4f5f17e..16f5428 100644 --- a/项目结构说明.md +++ b/项目结构说明.md @@ -56,7 +56,8 @@ offerpie/back-end │ ├─ RouteMenuMapper.java # 路由菜单Mapper │ ├─ FuncPermissionMapper.java # 功能权限Mapper │ ├─ UserRouteMenuStockMapper.java # 用户路由菜单库存Mapper - │ └─ UserFuncPermissionStockMapper.java # 用户功能权限库存Mapper + │ ├─ UserFuncPermissionStockMapper.java # 用户功能权限库存Mapper + │ └─ UserFuncUsageLogMapper.java # 用户功能使用记录Mapper ├─ pojo/ │ ├─ po/ # 持久化实体 │ │ ├─ User.java @@ -64,7 +65,8 @@ offerpie/back-end │ │ ├─ RouteMenu.java # 路由菜单表(bg_route_menu) │ │ ├─ FuncPermission.java # 功能权限表(bg_func_permission) │ │ ├─ UserRouteMenuStock.java # 用户路由菜单库存表(bg_user_route_menu_stock) - │ │ └─ UserFuncPermissionStock.java # 用户功能权限库存表(bg_user_func_permission_stock) + │ │ ├─ UserFuncPermissionStock.java # 用户功能权限库存表(bg_user_func_permission_stock) + │ │ └─ UserFuncUsageLog.java # 用户功能使用记录表(bg_user_func_usage_log) │ └─ vo/ # ViewObject(OssUrlVo 等) └─ server/ # 业务 Service(OssServer、SmsServer 等) ``` @@ -75,7 +77,7 @@ offerpie/back-end |------|----------|-----------| | **client-api** | - 面向终端用户的 REST API
- 启动 Spring Boot 应用
- 短信验证码登录(含自动注册)
- **功能权限校验**:注解 + 切面 + 权限服务(校验、扣减、回退)
- **路由菜单**:获取用户有效菜单树 | `ClientApplication`、`LoginController`、`RouteMenuController`、`FuncPermission`、`FuncPermissionAspect`、`FuncPermissionServer`、`RouteMenuServer`、`RouteMenuVo` | | **common** | - **统一配置**:OSS、Redis、Security、WxPay、Sms 等
- **跨层工具**:HTTP、IP、认证、验证码、Redis Server 等
- **全局拦截/切面**:日志、TraceId、黑名单、SQL 打印
- **统一异常/响应**:`GlobalExceptionAdvice`、`UnifiedResponse`
- **业务抽象**:邮件发送、微信支付(Native/JS/Transfer)
- **公共 POJO**:登录令牌、防重放信息等 | `config/`, `tool/`, `interceptor/`, `aop/`, `exception/`, `email/`, `wxPay/`, `pojo/` | -| **manager** | - **业务实体**(`User`、`OssFile`、`RouteMenu`、`FuncPermission`、`UserRouteMenuStock`、`UserFuncPermissionStock`)
- **MyBatis Mapper**(`UserMapper`、`OssFileMapper`、`RouteMenuMapper`、`FuncPermissionMapper`、`UserRouteMenuStockMapper`、`UserFuncPermissionStockMapper`)
- **业务 API**:文件上传/下载、健康检查等
- **业务逻辑**:服务层、工具类等
- **既供 B 端 UI(待实现)使用,也供 C 端业务直接调用** | `controller/`, `mapper/`, `pojo/po/`, `pojo/vo/`, `server/`, `constant/` | +| **manager** | - **业务实体**(`User`、`OssFile`、`RouteMenu`、`FuncPermission`、`UserRouteMenuStock`、`UserFuncPermissionStock`、`UserFuncUsageLog`)
- **MyBatis Mapper**(`UserMapper`、`OssFileMapper`、`RouteMenuMapper`、`FuncPermissionMapper`、`UserRouteMenuStockMapper`、`UserFuncPermissionStockMapper`、`UserFuncUsageLogMapper`)
- **业务 API**:文件上传/下载、健康检查等
- **业务逻辑**:服务层、工具类等
- **既供 B 端 UI(待实现)使用,也供 C 端业务直接调用** | `controller/`, `mapper/`, `pojo/po/`, `pojo/vo/`, `server/`, `constant/` | ## 3️⃣ 关键业务实体 | 实体 | 所属模块 | 作用概述 | @@ -83,9 +85,10 @@ offerpie/back-end | `User` | manager | 记录用户基础信息(手机号、邮箱、密码、昵称、微信绑定等),配合 `UserMapper` 完成持久化。 | | `OssFile` | manager | 描述 OSS(对象存储)中文件的元数据(路径、大小、标签等),通过 `OssFileMapper` 进行增删改查。 | | `RouteMenu` | manager | 路由菜单表(bg_route_menu),支持多级树形结构,通过 rootId/parentId 表达层级关系,openAccess 标识是否公开免费。 | -| `FuncPermission` | manager | 功能权限表(bg_func_permission),定义功能点编码(func_code,最长12字符,唯一约束)。 | +| `FuncPermission` | manager | 功能权限表(bg_func_permission),定义功能点编码(func_code,最长12字符,唯一约束),daily_free_count 配置每日免费次数。 | | `UserRouteMenuStock` | manager | 用户路由菜单库存表(bg_user_route_menu_stock),记录用户拥有的菜单权限,支持时间维度。 | | `UserFuncPermissionStock` | manager | 用户功能权限库存表(bg_user_func_permission_stock),记录用户拥有的功能权限,支持时间/次数/复合维度。 | +| `UserFuncUsageLog` | manager | 用户功能使用记录表(bg_user_func_usage_log),记录每次功能使用,用于免费次数统计和异常回退。 | | `RouteMenuVo` | client-api | 路由菜单树形VO,包含 children 子菜单列表,供前端渲染动态路由。 | | `LoginVo` | client-api | 登录成功后返回的用户信息(userId、nick)。 | | `SmsLoginDto` | client-api | 短信验证码登录的请求参数(mobileNumber、code)。 | @@ -96,13 +99,14 @@ offerpie/back-end - **后端**:AOP 切面拦截 `@FuncPermission` 注解,校验权限 + 扣减库存,业务异常自动回退次数 - **权限来源**:商品模块下单成功后写入库存表,权限框架不关心来源 -### 数据库表(4张) +### 数据库表(5张) | 表名 | 说明 | |------|------| | `bg_route_menu` | 路由菜单定义(树形结构,open_access 标识公开免费菜单) | | `bg_user_route_menu_stock` | 用户路由菜单库存(时间维度) | -| `bg_func_permission` | 功能权限定义(func_code 唯一) | +| `bg_func_permission` | 功能权限定义(func_code 唯一,daily_free_count 配置每日免费次数) | | `bg_user_func_permission_stock` | 用户功能权限库存(时间/次数/复合维度) | +| `bg_user_func_usage_log` | 用户功能使用记录(每次使用插入,用于免费次数统计和异常回退) | ### 库存维度 - `time_limit` + `expire_time`:时间维度,0=不限时,1=限时 @@ -112,12 +116,14 @@ offerpie/back-end ### 切面校验流程 1. 拿注解上的 funcCode + 当前登录用户 userId -2. 查库存表(唯一索引 userId + funcCode) -3. 无记录 → 无权限 -4. time_limit=1 且过期 → 已过期 -5. count_limit=0 → 直接放行 -6. count_limit=1 → SQL 原子扣减 `remain_count = remain_count - 1`(WHERE remain_count > 0) -7. 业务方法异常 → 自动回退次数 +2. 查 bg_func_permission(funcCode + status=1),拿到 dailyFreeCount +3. dailyFreeCount > 0 → COUNT 今日使用记录,未超额 → 插入使用记录,放行 +4. 免费额度用完或无免费额度 → 查付费库存表(userId + funcCode) +5. 无记录 → 无权限 +6. time_limit=1 且过期 → 已过期 +7. count_limit=0 → 插入使用记录,放行 +8. count_limit=1 → SQL 原子扣减 `remain_count = remain_count - 1`(WHERE remain_count > 0)→ 插入使用记录,放行 +9. 业务方法异常 → 删除使用记录 + 尝试回退库存次数(count_limit=1 时 remain_count + 1) ## 5️⃣ 共享技术栈(位于 `common`) | 类别 | 关键实现 | 位置 |