编码习惯与规范.md 18 KB

Forge-Hotel 编码习惯与规范

本文档记录个人在 Forge 项目中的编码习惯和偏好,所有代码编写(含 AI 辅助生成)必须遵循以下规范。

适用范围: forge-hotel 模块及后续所有业务模块 最后更新: 2026-08-17


一、后端编码习惯

1.1 依赖注入

  • 使用 @Autowired 字段注入,不使用 @RequiredArgsConstructor 构造器注入
  • 每个依赖字段单独加 @Autowired 注解
  • 示例: java @RestController @RequestMapping("/hotel/dish") public class HotelDishController { @Autowired private HotelDishService hotelDishService; }

1.2 Controller 查询参数

  • 分页查询接口的筛选参数必须用实体类接收,禁止逐个 @RequestParam
  • 只有 @PathVariable(路径参数如 id)和确实无法用实体类表达的参数(如安全校验参数)才单独接收
  • 状态更新类接口(如 /{id}/status)属于操作类,可保持 @RequestParam
  • 示例: ```java // ✅ 正确 public RespInfo> dishPage(PageQuery pageQuery, HotelDish query) { ... }
  • // ❌ 错误 public RespInfo> dishPage(PageQuery pageQuery,

    @RequestParam(required = false) Long categoryId,
    @RequestParam(required = false) String status,
    @RequestParam(required = false) String name) { ... }
    
    - Service 接口和 Mapper 方法签名同步使用实体类参数
    - Mapper XML 中用 `@Param("query")` 标注,引用时用 `#{query.fieldName}`
    
    ### 1.3 URL 路径风格(传统 JDK 7 风格)
    
    - **禁止使用 `{id}` 路径占位符**,统一使用方法名 + `@RequestParam` 方式传递 ID
    - 所有带 ID 参数的接口,ID 作为查询参数而非路径参数
    - 示例:
    ```java
    // ✅ 正确 - 使用 @RequestParam
    @GetMapping("/dish/detail")
    public RespInfo<HotelDishVO> dishDetail(@RequestParam Long id) { ... }
    
    @PostMapping("/dish/remove")
    public RespInfo<Void> dishDelete(@RequestParam Long id) { ... }
    
    @PutMapping("/dish/updateStatus")
    public RespInfo<Void> dishUpdateStatus(@RequestParam Long id, @RequestParam String status) { ... }
    
    // ❌ 错误 - 禁止使用 @PathVariable 和 {id}
    @GetMapping("/dish/{id}")
    public RespInfo<HotelDishVO> dishDetail(@PathVariable Long id) { ... }
    
    @PostMapping("/dish/remove/{id}")
    public RespInfo<Void> dishDelete(@PathVariable Long id) { ... }
    
    • 路径命名规则:
      • 详情查询:/xxx/detail(原 /{id}
      • 删除:/xxx/remove(原 /remove/{id}/{id}
      • 状态更新:/xxx/updateStatus(原 /{id}/status
      • 其他操作:直接用动词方法名,如 /xxx/accept/xxx/reject/xxx/toggle
    • 前端调用时,ID 放在 params 中: ```javascript // ✅ 正确 request.get('/hotel/dish/detail', { params: { id } }) request.post('/hotel/dish/remove', null, { params: { id } }) request.put('/hotel/dish/updateStatus', null, { params: { id, status } })

    // ❌ 错误 request.get(/hotel/dish/${id}) request.post(/hotel/dish/remove/${id})

    
    ### 1.4 Java 版本限制(JDK 7 及以下风格)
    
    - **禁止使用 Java 8+ 特性**,包括:
      - Lambda 表达式(`() -> {}`)
      - 方法引用(`ClassName::methodName`)
      - Stream API(`.stream().filter().map().collect()`)
      - 增强 switch 表达式(`case "X" -> value`)
    - 替代方案:
      - Lambda → 匿名内部类
      - Stream → 传统 for 循环 + if 判断
      - 方法引用 → 直接调用或使用字符串列名
      - 增强 switch → 传统 switch + break
    - 示例:
    ```java
    // ✅ 正确 - 传统 switch
    for (Long id : dto.getIds()) {
        switch (dto.getOperation()) {
            case "ON_SALE":
                dishUpdateStatus(id, "ON_SALE");
                break;
            case "DELETE":
                dishDelete(id);
                break;
            default:
                throw new BusinessException("不支持的操作类型: " + dto.getOperation());
        }
    }
    
    // ❌ 错误 - 增强 switch
    for (Long id : dto.getIds()) {
        switch (dto.getOperation()) {
            case "ON_SALE" -> dishUpdateStatus(id, "ON_SALE");
            case "DELETE" -> dishDelete(id);
            default -> throw new BusinessException("不支持的操作类型: " + dto.getOperation());
        }
    }
    
    // ✅ 正确 - 传统 for 循环过滤
    List<String> boundShortCodes = new ArrayList<>();
    for (HotelQrCode e : entities) {
        if (Objects.equals(e.getTenantId(), tenantId) && e.getRoomId() != null) {
            boundShortCodes.add(e.getShortCode());
        }
    }
    
    // ❌ 错误 - Stream API
    List<String> boundShortCodes = entities.stream()
        .filter(e -> Objects.equals(e.getTenantId(), tenantId))
        .filter(e -> e.getRoomId() != null)
        .map(HotelQrCode::getShortCode)
        .toList();
    
    // ✅ 正确 - 匿名内部类
    HotelQrCode qrCode = TenantContextHolder.executeIgnore(new java.util.function.Supplier<HotelQrCode>() {
        @Override
        public HotelQrCode get() {
            return getBaseMapper().selectByShortCodeIgnoreTenant(shortCode);
        }
    });
    
    // ❌ 错误 - Lambda 表达式
    HotelQrCode qrCode = TenantContextHolder.executeIgnore(() ->
        getBaseMapper().selectByShortCodeIgnoreTenant(shortCode)
    );
    
    // ✅ 正确 - QueryWrapper + 字符串列名
    QueryWrapper<HotelDishSpecGroup> wrapper = new QueryWrapper<>();
    wrapper.eq("dish_id", dishId)
           .eq("tenant_id", tenantId)
           .orderByAsc("sort_order");
    
    // ❌ 错误 - LambdaQueryWrapper + 方法引用
    LambdaQueryWrapper<HotelDishSpecGroup> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(HotelDishSpecGroup::getDishId, dishId)
           .eq(HotelDishSpecGroup::getTenantId, tenantId)
           .orderByAsc(HotelDishSpecGroup::getSortOrder);
    
    // ✅ 正确 - UpdateWrapper + 字符串列名
    UpdateWrapper<HotelQrCode> updateWrapper = new UpdateWrapper<>();
    updateWrapper.eq("id", qrCodeId)
                 .set("room_id", null)
                 .set("room_no", null);
    getBaseMapper().update(null, updateWrapper);
    
    // ❌ 错误 - LambdaUpdateChainWrapper + 方法引用
    new LambdaUpdateChainWrapper<>(getBaseMapper())
        .eq(HotelQrCode::getId, qrCodeId)
        .set(HotelQrCode::getRoomId, null)
        .set(HotelQrCode::getRoomNo, null)
        .update();
    
    • MyBatis-Plus Wrapper 选择:
      • 使用 QueryWrapper / UpdateWrapper(字符串列名)
      • 禁止使用 LambdaQueryWrapper / LambdaUpdateChainWrapper(方法引用)
      • 原因:方法引用属于 Java 8+ 特性,不符合传统 JDK 7 风格

    1.5 统一返回封装

    • 所有接口统一使用 RespInfo 封装返回值
    • 成功:RespInfo.success(data)RespInfo.success()(无返回值时)
    • 失败:RespInfo.error(msg) 或抛出 BusinessException

    1.6 实体类规范

    • 实体继承 TenantEntity(自带 tenantId + 审计字段)
    • 主键使用 @TableId(value = "id", type = IdType.ASSIGN_ID)(雪花算法)
    • 逻辑删除字段:@TableLogic(value = "0", delval = "id"),类型为 Long(bigint),删除时写入主键 ID
    • 使用 Lombok:@Data + @EqualsAndHashCode(callSuper = true) + @Serial
    • 每个字段必须有 Javadoc 注释
    • 示例:

      @Data
      @EqualsAndHashCode(callSuper = true)
      @TableName("hotel_business_hours")
      public class HotelBusinessHours extends TenantEntity {
      @Serial
      private static final long serialVersionUID = 1L;
      
      /** 主键ID(雪花算法) */
      @TableId(value = "id", type = IdType.ASSIGN_ID)
      private Long id;
      
      /** 状态: ENABLED-启用, DISABLED-禁用 */
      private String status;
      
      /** 删除标记(0=未删除,删除时写入主键ID) */
      @TableLogic(value = "0", delval = "id")
      private Long delFlag;
      }
      

    1.5 SQL 编写规范

    • SQL 必须写在 Mapper XML 中,禁止在 Service 层用 LambdaQueryWrapper 构建查询
    • 例外:仅 selectByIdinsertupdateByIddeleteById 等 MyBatis-Plus 内置方法允许
    • 原因:DataScopeInterceptormapperMethod 精确匹配改写 SQL
    • Mapper XML 中的查询必须显式过滤未删除数据:AND del_flag = 0
    • 原因:自定义 XML SQL 不会被 MP 自动补全逻辑删除条件

    1.6 操作日志

    • 每个 Controller 方法必须加 @OperationLog 注解
    • 格式:@OperationLog(module = "模块名", type = OperationType.XXX, desc = "操作描述")
    • 操作类型:QUERY / ADD / UPDATE / DELETE

    1.7 包结构

    controller/     # REST 控制器
    domain/         # 数据库实体(Entity)
    dto/            # 请求 DTO(新增/修改用)
    vo/             # 响应 VO(查询返回用)
    mapper/         # MyBatis Mapper 接口
    service/        # 服务接口
      └── impl/     # 服务实现
    constant/       # 常量
    

    1.8 Service 层规范

    • Service 之间禁止互相注入(避免循环依赖)
    • 跨 Service 协调逻辑上提到 Controller 层
    • 事务注解:@Transactional(rollbackFor = Exception.class)
    • 校验方法命名:validateXxx(Xxx entity/dto)
    • 存在性校验方法命名:requireXxx(Long id)(不存在直接抛异常)

    二、前端编码习惯

    2.1 页面组件选择

    • 标准 CRUD 页面:使用 AiCrudPage 组件(自动处理搜索栏、工具栏、表格、分页布局)
    • 分页位置:右下角(AiCrudPage 内置)
    • 配置少的简单列表:不加多余筛选条件,保持界面简洁
    • 页面容器样式:height: 100%

    2.2 AiCrudPage 配置规范

    • api-config 格式: javascript :api-config="{ list: 'get@/hotel/xxx/page', detail: 'get@/hotel/xxx/detail', add: 'post@/hotel/xxx', update: 'put@/hotel/xxx', delete: 'post@/hotel/xxx/remove', }"
    • 注意:detail 和 delete 路径中不包含 :id,ID 由组件自动作为查询参数传递
    • tableColumnseditSchema 必须定义为 computed(确保字典异步加载后响应式更新)
    • 操作列使用 h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: ... }) 模式
    • 刷新方法:crudRef.value?.refresh()(不是 reload)
    • 编辑方法:crudRef.value?.handleEdit(row)

    2.3 字段命名

    • 前端字段统一使用 camelCase 命名
    • 与后端 API 返回字段保持一致

    2.4 表格渲染规范

    • 空值处理:空值字段显示空白,不使用 - 作为占位符
    • 图片列
      • 列宽设置 100px
      • div 包裹 AuthImage 组件,设置 overflow: hidden 防止图片溢出
      • 禁止直接用 NAvatar 的 src 属性
    • 状态标签:使用 NTag 组件,配合颜色映射 javascript const statusColorMap = { ENABLED: 'success', DISABLED: 'default' } render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, { default: () => statusLabelMap[row.status] || row.status })

    2.5 操作按钮样式

    使用 UnoCSS 语义化颜色类区分操作类型:

    类名 颜色 场景
    text-primary 编辑、查看、授权
    text-warning 刷新缓存、重置、封禁
    text-error 删除、强制下线
    text-success 绿 启用、发布、通过
    text-info 灰蓝 详情、统计

    2.6 字典使用

    • 禁止硬编码下拉选项和状态标签
    • 下拉使用 DictSelect 组件或 useDict() composable
    • 标签展示使用 DictTag 组件
    • Schema 中的字典选项必须用 computed 包裹

    2.7 图片预览

    • 采用全屏覆盖层实现(Teleport to="body"
    • position: fixed + 半透明黑色背景
    • 右上角 × 关闭按钮,支持点击背景或 ESC 关闭
    • 左右箭头切换图片,底部圆点指示器 + 页码计数
    • 图片自适应:max-width: 85vw + max-height: 80vh

    2.8 时间段选择

    • 时间类字段使用下拉选择(Select),不使用手动输入框
    • 时间选项按业务场景设定间隔(如每 30 分钟一档:09:00, 09:30, 10:00...23:30)

    2.9 API 文件规范

    • 导入路径:import { request } from '@/utils'(不是 @/utils/request
    • 函数命名:getXxxPage / getXxxDetail / createXxx / updateXxx / deleteXxx
    • 按模块用注释分隔:// ==================== 模块名 ====================
    • 每个函数加 JSDoc 注释

    2.10 API 路径风格(与后端保持一致)

    • 禁止使用模板字符串拼接 ID(如 /hotel/dish/${id}
    • 所有带 ID 的接口,ID 作为查询参数传递
    • 示例: ```javascript // ✅ 正确 - ID 放在 params 中 export function getDishDetail(id) { return request.get('/hotel/dish/detail', { params: { id } }) }

    export function deleteDish(id) { return request.post('/hotel/dish/remove', null, { params: { id } }) }

    export function updateDishStatus(id, status) { return request.put('/hotel/dish/updateStatus', null, { params: { id, status } }) }

    // ❌ 错误 - 禁止使用模板字符串拼接 ID export function getDishDetail(id) { return request.get(/hotel/dish/${id}) }

    export function deleteDish(id) { return request.post(/hotel/dish/remove/${id}) }

    - 路径映射规则(与后端 Controller 对应):
      - 详情:`/xxx/:id` → `/xxx/detail` + `{ params: { id } }`
      - 删除:`/xxx/remove/:id` → `/xxx/remove` + `{ params: { id } }`
      - 状态更新:`/xxx/:id/status` → `/xxx/updateStatus` + `{ params: { id, status } }`
      - 其他操作:`/xxx/:id/action` → `/xxx/action` + `{ params: { id } }`
    
    ---
    
    ## 三、数据库编码习惯
    
    ### 3.1 表结构规范
    
    - 所有业务表必须包含:`id`, `tenant_id`, `create_by`, `create_time`, `create_dept`, `update_by`, `update_time`
    - 字符集 `utf8mb4`,引擎 `InnoDB`
    - 主键用 `bigint`(雪花算法),不用自增
    - 金额字段用 `bigint`,单位**分**
    - 时间字段用 `datetime`(对应 Java `LocalDateTime`)
    - 状态字段用 `varchar`,值为大写英文(如 `ENABLED` / `DISABLED`、`ON_SALE` / `OFF_SHELF`)
    
    ### 3.2 逻辑删除规范
    
    - 业务表默认使用逻辑删除
    - `del_flag` 字段类型 `bigint NOT NULL DEFAULT 0`
    - 删除时写入当前行主键 ID(不是固定值 `1`)
    - 实体必须显式声明 `@TableLogic(value = "0", delval = "id")`
    
    ### 3.3 Flyway 迁移脚本
    
    - 命名格式:`V<版本号>__<lower_snake_case_description>.sql`
    - 版本号必须大于 `1.0.0`,单调递增
    - 脚本必须具备**防重复保护**:
      - 建表:`CREATE TABLE IF NOT EXISTS`
      - 插入:`INSERT ... SELECT ... WHERE NOT EXISTS`
      - 新增列/索引前查 `information_schema`
    - `INSERT` 必须显式写列名,禁止依赖表字段顺序
    - 业务内置数据 `tenant_id` 必须为 `1`,禁止写 `0`
    - 菜单资源 SQL 使用动态 `parent_id`(子查询),避免硬编码 ID
    - 字典数据格式:`sys_dict_type` + `sys_dict_data`,`dict_value` 与后端枚举值保持一致
    
    ### 3.4 字典规范
    
    - 字典类型命名:小写下划线,业务级用 `hotel_` 前缀,系统级用 `sys_` 前缀
    - `dict_value` 必须与后端枚举/状态值严格一致
    - `dict_label` 只负责展示文案
    - `list_class` 负责标签样式(success/primary/warning/info/default)
    - 每个字典数据单独一行 INSERT + WHERE NOT EXISTS 防重复
    
    ---
    
    ## 四、API 设计习惯
    
    ### 4.1 RESTful 风格(传统 JDK 7 风格)
    
    | 操作 | HTTP 方法 | 路径 | 说明 |
    |------|-----------|------|------|
    | 分页查询 | GET | `/xxx/page` | 筛选参数用实体类接收 |
    | 查询全部 | GET | `/xxx/all` 或 `/xxx/list` | 不分页列表 |
    | 查询详情 | GET | `/xxx/detail` | **ID 作为 `@RequestParam`**,不是路径参数 |
    | 新增 | POST | `/xxx` | RequestBody |
    | 修改 | PUT | `/xxx` | RequestBody |
    | 删除 | POST | `/xxx/remove` | **ID 作为 `@RequestParam`**,不是路径参数 |
    | 状态切换 | POST | `/xxx/toggle` | **ID 作为 `@RequestParam`** |
    | 状态更新 | PUT | `/xxx/updateStatus` | `@RequestParam Long id, @RequestParam String status` |
    | 批量操作 | POST | `/xxx/batch` | RequestBody |
    
    - **禁止使用 `{id}` 路径占位符**
    - 所有带 ID 的接口统一使用方法名 + `@RequestParam` 方式
    - 示例:
    ```java
    // ✅ 正确
    @GetMapping("/dish/detail")
    public RespInfo<HotelDishVO> dishDetail(@RequestParam Long id) { ... }
    
    @PostMapping("/dish/remove")
    public RespInfo<Void> dishDelete(@RequestParam Long id) { ... }
    
    @PutMapping("/dish/updateStatus")
    public RespInfo<Void> dishUpdateStatus(@RequestParam Long id, @RequestParam String status) { ... }
    
    // ❌ 错误 - 禁止使用 @PathVariable
    @GetMapping("/dish/{id}")
    public RespInfo<HotelDishVO> dishDetail(@PathVariable Long id) { ... }
    

    4.2 权限标识

    • 格式:模块:资源:操作,如 hotel:dish:queryhotel:businessHours:add
    • 按钮权限资源类型 resource_type = 3

    五、交互设计习惯

    5.1 列表页面

    • 配置项少的列表页不加多余筛选(如营业时段不加餐段筛选)
    • 分页统一在右下角
    • 操作列包含:编辑 + 状态切换 + 删除

    5.2 批量操作

    • 有批量生成的管理页面,应同步提供批量删除、批量下载(ZIP)、单条下载功能
    • 保证操作完整性与用户体验一致性

    5.3 危险操作

    • 删除、解绑等不可逆操作必须弹出二次确认
    • 已绑定的二维码显示"解绑"按钮而非"重新绑定"

    5.4 扫码绑定

    • 绑定成功后,点击"完成"跳转至扫码绑定主界面(无参数状态)
    • 解绑后返回扫码界面

    六、多租户习惯

    6.1 租户上下文

    • 业务数据 tenant_id 必须设为 1(默认租户),禁止设 0
    • 免登录场景(如扫码):先忽略租户过滤查到实体,再根据实体的 tenantId 动态设置租户上下文
    • 使用 TenantContextHolder.getTenantId() 获取当前租户 ID
    • 兜底逻辑:return tenantId == null ? 1L : tenantId;

    七、其他习惯

    7.1 注释风格

    • 实体类每个字段必须有 Javadoc 注释
    • Controller 每个方法必须有 Javadoc(描述接口用途)
    • Service 接口方法必须有 Javadoc(含参数说明)
    • 关键业务逻辑加行内注释说明

    7.2 安全红线

    • 禁止硬编码密钥、AK/SK、数据库密码
    • 禁止在日志中打印手机号、身份证、银行卡
    • API Key/Secret 返回前端必须脱敏(保留前4后4,中间 ****

    7.3 构建与验证

    • Maven 构建:mvn compile -pl forge-business/forge-hotel -am -DskipTests
    • 前端 Lint:pnpm lint:fix
    • 改完代码必须编译/Lint 验证通过