--- name: "java-code-standard" description: "SaaS 多租户 Java 后端代码开发规范。在编写、修改或审查任何 Java 后端代码时(Controller、Service、Mapper、配置类、工具类)必须遵循此规范。" --- # Java 后端代码开发规范 ## 语言 - 代码注释、日志信息、异常消息**一律使用中文** - 类名、方法名、变量名使用英文(驼峰命名) --- ## 项目结构 ### 前端项目 | 目录 | 别名 | 说明 | |------|------|------| | `adminui` | 总后台 | 平台管理端,管理所有租户、定价、系统配置等 | | `agentui` | 代理后台 | 代理商管理端 | | `saas-mgnui` | 租户后台 | 租户自管理后台,管理租户下的公司、用户、课程等 | | `saas-companyui` | 销售后台 | 销售/公司端后台,管理销售团队、客户、订单等 | ### 后端模块 ``` fs-common — 公共工具、BaseController、RedisCache、TenantLock、RedisTenantContext、常量等 fs-service — 业务接口层:Service/ServiceImpl、Mapper、Domain、枚举、工具类 fs-admin — 接口模块:总后台(adminui 的后端) fs-saas-admin — 接口模块:租户后台(saas-mgnui 的后端) fs-saas-company — 接口模块:销售后台(saas-companyui 的后端) fs-user-app — 客户端(包括商城、看课相关接口) fs-task — 定时任务模块 ``` ### 前端 ↔ 后端对应关系 | 前端项目 | 主要后端模块 | 调用路径特征 | |---------|------------|-------------| | `adminui` | `fs-admin` | `/admin/*`、`/system/*`、`/course/*`(跨租户) | | `agentui` | `fs-admin` / 部分代理专用接口 | `/admin/*`、代理专用路径 | | `saas-mgnui` | `fs-saas-admin` | `/course/*`、`/qw/*`、`/company/*` | | `saas-companyui` | `fs-saas-company` | `/course/*`、`/qw/*`、`/company/*`(租户内) | --- ## 命名规范 ### 类命名 | 类型 | 规范 | 示例 | |------|------|------| | Controller | `XxxController` | `AdminConsumeRecordController` | | Service 接口 | `IXxxService` | `ITokenBalanceService` | | Service 实现 | `XxxServiceImpl` | `TokenBalanceServiceImpl` | | Mapper | `XxxMapper` | `TenantWalletTxnMapper` | | 实体 | `Xxx` (与表名对应) | `TenantWalletTxn` | | VO | `XxxVO` | `ConsumeRecordExportVO` | | 枚举 | `XxxEnum` | `ConsumeTypeEnum` | | 工具类 | `XxxUtils` / `XxxHelper` | `BillingLabelUtils` | | 配置类 | `XxxConfig` | `RedisConfig` | | 常量 | `FsConstants`(集中) 或 `XxxConstants` | — | ### 方法命名 | 操作 | Controller | Service | |------|------------|---------| | 分页列表 | `list()` | `selectXxxList()` | | 详情 | `getInfo()` | `selectXxxById()` | | 新增 | `add()` | `insertXxx()` | | 修改 | `edit()` | `updateXxx()` | | 删除 | `remove()` | `deleteXxxByIds()` | | 导出 | `export()` | — | ### 字段命名 - 实体字段与数据库列名对应(下划线 → 驼峰) - 布尔字段用 `isXxx` 前缀 - 金额用 `BigDecimal`,数量用 `Long` 或 `Integer` --- ## Controller 规范 ```java @RestController @RequestMapping("/admin/xxx") public class AdminXxxController extends BaseController { @Autowired private SomeMapper someMapper; @PreAuthorize("@ss.hasPermi('admin:xxx:list')") @GetMapping("/list") public TableDataInfo list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) String name) { startPage(); // ← 分页 List list = someMapper.selectList(wrapper); return getDataTable(list); // ← 返回 TableDataInfo } @GetMapping("/{id}") public AjaxResult getInfo(@PathVariable Long id) { return AjaxResult.success(someMapper.selectById(id)); } } ``` ### 要点 1. Controller **必须继承 `BaseController`** 2. 分页查询**必须先调 `startPage()`**(PageHelper),再查 Mapper 3. 列表返回 `TableDataInfo`(`getDataTable(list)`),详情返回 `AjaxResult` 4. 需要权限控制的加 `@PreAuthorize("@ss.hasPermi('xxx')")` 5. 入参用 `@RequestParam`(GET)或 `@RequestBody`(POST) 6. 当 GET 方法的 `@RequestParam` 参数**超过 3 个**时,必须封装为请求实体类(`XxxQueryReq`),包含分页参数、筛选条件等 ```java // 错误:参数过多 ← 反例 public TableDataInfo list(@RequestParam(required = false) String bizType, @RequestParam(required = false) Long companyId, @RequestParam(required = false) String beginTime, @RequestParam(required = false) String endTime) { ... } // 正确:封装为请求实体 ← 正例 @Data public class ConsumeRecordQueryReq { private Integer pageNum; private Integer pageSize; private String bizType; private Long companyId; private String beginTime; private String endTime; } public TableDataInfo list(ConsumeRecordQueryReq req) { Long tenantId = SecurityUtils.getTenantId(); PageDomain pageDomain = TableSupport.buildPageRequest(); // ... } ``` 7. 请求实体类放在对应 Controller 模块的 `domain` 包下,命名规范:`XxxQueryReq`(查询)或 `XxxReq`(普通请求) --- ## Service 规范 ```java public interface IXxxService { List selectXxxList(Xxx xxx); Xxx selectXxxById(Long id); int insertXxx(Xxx xxx); int updateXxx(Xxx xxx); int deleteXxxByIds(Long[] ids); } @Slf4j @Service public class XxxServiceImpl implements IXxxService { @Autowired private XxxMapper xxxMapper; @Override public List selectXxxList(Xxx xxx) { return xxxMapper.selectXxxList(xxx); } } ``` ### 要点 1. Service 接口用 `I` 前缀,实现类用 `Impl` 后缀 2. 实现类加 `@Service` 和 `@Slf4j` 3. 依赖注入用 `@Autowired` 字段注入 4. 事务用 `@Transactional(rollbackFor = Exception.class)` 5. 所有日志用 `log.info/warn/error` 打印关键参数 --- ## 多租户切库 ### 切数据库 ```java // ThreadLocal 设置数据源 DynamicDataSourceContextHolder.setDataSourceType("tenant:123"); // 清理 DynamicDataSourceContextHolder.clearDataSourceType(); ``` ### 切 Redis 缓存 ```java // 必须与切库同步设置,否则 RedisCache 拿不到租户前缀 RedisTenantContext.setTenantId(tenantId); // 清理 RedisTenantContext.clear(); ``` ### 定时任务切库模式(fs-ipad-task) 定时任务**不依赖 `@TenantDataScope` 切面**(线程池异步丢失上下文),须显式切库: ```java // === 主线程 === sopTenantDataSourceAspect.switchTenant(tenantId); try { // 业务逻辑 } finally { sopTenantDataSourceAspect.clear(); } // === 异步线程 === CompletableFuture.runAsync(() -> { sopTenantDataSourceAspect.switchTenant(tenantId); try { // 业务逻辑 } finally { sopTenantDataSourceAspect.clear(); } }); ``` > `switchTenant()` 内部同时设置 `DynamicDataSourceContextHolder` 和 `RedisTenantContext`,无需额外操作。 --- ## Redis 缓存规范 ### 使用 RedisCache(自动加租户前缀) ```java @Autowired private RedisCache redisCache; // 读 String val = redisCache.getCacheObject("company:token:balance:" + companyId); // 写 redisCache.setCacheObject("company:token:balance:" + companyId, String.valueOf(balance)); ``` > `RedisCache` 使用 `TenantKeyRedisSerializer`,自动在 key 前加 `tenantid:{租户ID}:` 前缀。 > **禁止**使用 `StringRedisTemplate` 操作业务缓存,除非确实不需要租户隔离。 ### 分布式锁(TenantLock) ```java @Autowired private TenantLock tenantLock; RLock lock = tenantLock.getLock(FsConstants.COMPANY_TOKEN_LOCK + companyId); if (lock.tryLock(3, 5, TimeUnit.SECONDS)) { try { // 临界区 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } ``` > `TenantLock` 自动加租户前缀,实现租户级锁隔离。**禁止**直接使用 `RedissonClient.getLock()` 操作租户数据。 --- ## Mapper / MyBatis 规范 - Mapper 接口放在各模块的 `mapper` 包下 - XML 放在 `resources/mapper/xxx/` 目录 - 使用 MyBatis-Plus 的 `BaseMapper`,简单 CRUD 直接调用父类方法 - 复杂查询在 XML 中写 SQL,方法定义在 Mapper 接口中 - 多个参数用 `@Param` 标注 ```java public interface CompanyMapper extends BaseMapper { List selectCompanyByIds2(@Param("companyIds") Set companyIds); } ``` --- ## 异常处理 ```java // 业务异常 throw new CustomException("租户不存在"); // 运行时异常 throw new RuntimeException("系统异常,请稍后重试"); ``` - 可预期的业务异常用 `CustomException` - 不可预期的系统异常用 `RuntimeException` - 不要吞异常:catch 后必须打日志或向上抛 --- ## 日志规范 ```java log.info("Token充值成功, companyId:{}, amount:{}", companyId, amount); log.warn("获取锁失败, companyId:{}", companyId); log.error("Token扣减异常, companyId:{}, amount:{}", companyId, amount, e); ``` 1. 日志用 `{}` 占位符,不要字符串拼接 2. 打印关键业务参数(ID、金额、数量) 3. `error` 级别必须把异常对象 `e` 作为最后一个参数 4. 不要打印敏感信息(密码、手机号明文) --- ## 常量定义 常量统一放在 `fs-common/constant/FsConstants.java`: ```java public class FsConstants { /** Token 余额缓存 Key 前缀 */ public static final String COMPANY_TOKEN_KEY = "company:token:balance:"; /** Token 分布式锁 Key 前缀 */ public static final String COMPANY_TOKEN_LOCK = "company:token:lock:"; } ``` > 避免魔法字符串散落在各处。 --- ## 代码风格 1. **缩进**:4 空格 2. **行宽**:不超过 120 字符 3. **空行**:方法之间空一行,逻辑块之间空一行 4. **注释**:复杂逻辑加行内注释,方法加 Javadoc(至少一句话说明用途) 5. **import**:不用通配符 `*`,IDE 自动整理 6. **字段顺序**:`@Autowired` 依赖 → 普通字段 → 常量 7. **不要**:未使用的 import、未使用的变量、`System.out.println`、`e.printStackTrace()` 8. **正则表达式可读性**:`Pattern.compile()` 中**禁止**使用 Unicode 转义字符(`\uXXXX`)表示中文字符,直接用中文字面量。转义序列对阅读和维护不友好。 ```java // 错误:Unicode 转义,完全不可读 ← 反例 private static final Pattern P = Pattern.compile( "\u662f\u4e0d\u662f\u505a|\u662f\u505a.*\u7684\u5417"); // 正确:直接写中文,一目了然 ← 正例 private static final Pattern P = Pattern.compile( "是不是做|是做.*的吗|是做.*吗|你们是卖|你们卖|你们是做|你们做|你们是搞" + "|是不是卖|有没有卖|是做.*销售|业务范围|你们干什么的|你们是做什么的|你们主要做" + "|你们家是|你们公司是做|你们这边是做|你们这是做"); ``` --- ## 数据库规范 1. 表名、列名:小写 + 下划线(`tenant_wallet_txn`) 2. 必须有主键(`id`),使用自增或雪花算法 3. 时间字段用 `datetime`,对应 Java 的 `Date` 或 `LocalDateTime` 4. 金额字段用 `decimal(18,2)`,对应 `BigDecimal` 5. 软删除用 `is_del` 字段(0 正常 1 删除) --- ## 前端新增/修改页面规范 **重要:当新增或修改任何前端页面时,必须在回复末尾输出以下清单,供用户在系统中录入菜单和权限。** ### 输出格式 #### 1. 页面文件路径 | 系统 | 文件路径 | 菜单名称 | |------|---------|---------| | `saas-mgnui` | `src/views/xxx/index.vue` | XX管理 | | `saas-companyui` | `src/views/xxx/index.vue` | XX管理 | #### 2. API 文件路径 | 系统 | API 文件路径 | |------|------------| | `saas-mgnui` 或 `saas-companyui` | `src/api/xxx/xxx.js` | #### 3. 权限标识符 从后端 Controller 的 `@PreAuthorize("@ss.hasPermi('xxx')")` 注解中提取: | 权限标识符 | 说明 | |----------|------| | `module:page:list` | 列表查询 | | `module:page:export` | 导出 | | `module:page:add` | 新增 | | `module:page:edit` | 修改 | | `module:page:remove` | 删除 | #### 4. 菜单录入信息(系统管理→菜单管理) | 系统 | 菜单名称 | 权限标识 | 组件路径 | 路由地址 | |------|---------|---------|---------|---------| | saas-mgnui | XX管理 | `module:page:list` | `xxx/index` | `xxx` | ### 规则 1. **路由不需要修改前端 router 文件**,系统通过后端菜单表动态生成路由 2. 菜单组件路径格式:相对于 `src/views/` 的路径,不含 `.vue` 后缀 3. 权限标识符必须与后端 Controller 的 `@PreAuthorize` 完全一致 4. 如果页面涉及多个系统(如同时需要租户后台和销售后台),必须分别列出各系统的文件路径和菜单信息 --- ## 检查清单 提交代码前确认: - [ ] Controller 继承了 `BaseController`,分页调了 `startPage()` - [ ] 多租户场景:切库 + 切缓存 + 锁都要有租户前缀 - [ ] Redis 操作使用 `RedisCache`(非 `StringRedisTemplate`) - [ ] 分布式锁使用 `TenantLock.getLock()`(非 `RedissonClient` 裸调) - [ ] 日志中文、占位符、关键参数完整 - [ ] 无硬编码魔法值(用常量或枚举) - [ ] 无 `e.printStackTrace()`、`System.out.println` - [ ] 无未使用的 import