编码习惯与规范.md 41 KB

Forge-Hotel 编码习惯与规范

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

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


一、后端编码习惯

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 构建查询
    • 例外:仅 selectById、insert、updateById、deleteById 等 MyBatis-Plus 内置方法允许
    • 原因:DataScopeInterceptor 按 mapperMethod 精确匹配改写 SQL
    • Mapper XML 中的查询必须显式过滤未删除数据:AND del_flag = 0
    • 原因:自定义 XML SQL 不会被 MP 自动补全逻辑删除条件
    • FOR UPDATE 语句禁止写 LIMIT(实际踩过的坑):租户拦截器 TenantLineInnerInterceptor 会用 JSqlParser 4.9 对所有查询 SQL 重新解析序列化,序列化时把 LIMIT 1 FOR UPDATE 重排为 FOR UPDATE LIMIT 1,MySQL 直接语法错误(审批同意/驳回时报 SQLSyntaxErrorException);纯 FOR UPDATE(无 LIMIT)往返不变
      • 规则:Mapper XML 锁行查询只写 WHERE 唯一条件 + FOR UPDATE,不加 LIMIT,并在 XML 中加防回归注释;条件不唯一时先把条件改唯一,而不是靠 LIMIT 兜
      • 已修复参考:FlowTaskMapper.selectByTaskIdForUpdate、CapabilityApprovalMapper.selectForUpdate

    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 由组件自动作为查询参数传递
    • tableColumns 和 editSchema 必须定义为 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 } }`
    
    ### 2.11 只读列表页与工具栏/导出按钮展示习惯
    
    - **只读列表页(仅查询 + 详情 + 导出)同样使用 `AiCrudPage`**,不要手写筛选栏 + `NDataTable`,保证与菜品规格等页面外观一致:
      - 隐藏写操作:`:hide-add="true"`、`:hide-batch-delete="true"`、`:hide-selection="true"`
      - 富详情(描述区 + 子表)不用组件内建 detail,用自定义操作列触发自己的弹窗
    - **导出按钮位置**(二选一,按页面需要):
      - 内建导出:`:show-export="true"` + `export-api` + `export-file-name`,按钮在工具栏**左侧**;当工具栏溢出操作只有导出 1 项时直接显示为独立按钮,≥2 项才折叠进「更多」下拉
      - 靠表格工具栏**右侧**(与刷新/密度/列设置等图标同一排):用 `#table-toolbar-right` 插槽放自定义按钮,导出参数用 `crudRef.value?.getSearchParams()` 获取(已经过 `before-search` 转换)
    - **日期范围筛选**:search-schema 用 `type: 'daterange'`(或 `datetimerange`),在 `before-search` 钩子中拆为 `beginTime`/`endTime` 并删除原字段;该钩子结果**同时作用于列表查询与导出**;后端列表接口必须同步支持 `beginTime`/`endTime`,否则筛选是「死」的
    - **日期范围转换必须统一走共享工具 `@/utils/date-range`,禁止各页面手写取下标直传**(此类转换事故已反复出现多次):
      - `applyDateRange(params, 'createTime', 'beginTime', 'endTime')` 一步完成「范围 → begin/end + 删原字段」,兼容毫秒时间戳 / 字符串 / Date 等组件产出形态;范围为空时同时移除 begin/end
      - `datetimerange` 传第 5 个参数 `true`,直传 `YYYY-MM-DD HH:mm:ss`
      - 后端时间归一化方法(如 `normalizeTime`)必须兼容多格式:13 位毫秒时间戳、`yyyyMMdd`、`yyyy-MM-dd`、带 `T` 的 ISO、`yyyy-MM-dd HH:mm:ss`;且结束端为零点 `00:00:00` 时抬升为 `23:59:59`,防止结束日期整天被排除
    - 示例(只读列表 + 右侧导出):
    ```vue
    <AiCrudPage
      ref="crudRef"
      api="/restaurant/dishOrder"
      :api-config="{ list: 'get@/restaurant/dishOrder/page' }"
      :search-schema="searchSchema"
      :columns="tableColumns"
      :before-search="beforeSearch"
      row-key="id"
      :hide-add="true"
      :hide-batch-delete="true"
      :hide-selection="true"
    >
      <template #table-toolbar-right>
        <NButton size="small" quaternary :loading="exportLoading" @click="handleExport">导出</NButton>
      </template>
    </AiCrudPage>
    
    // 日期范围 → beginTime/endTime(同时作用于列表与导出)
    // 统一走共享工具,禁止手写 range[0]/range[1] 直传
    import { applyDateRange } from '@/utils/date-range'
    
    function beforeSearch(params) {
      return applyDateRange(params, 'createTime', 'beginTime', 'endTime')
    }
    
    // 右侧自定义导出:取组件已转换的搜索参数
    async function handleExport() {
      const params = crudRef.value?.getSearchParams?.() || {}
      const response = await exportOrder(params)
      downloadBlobResponse(response, '导出.xlsx')
    }
    

    三、数据库编码习惯

    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 dishDetail(@RequestParam Long id) { ... }
    • @PostMapping("/dish/remove") public RespInfo dishDelete(@RequestParam Long id) { ... }

      @PutMapping("/dish/updateStatus") public RespInfo dishUpdateStatus(@RequestParam Long id, @RequestParam String status) { ... }

      // ❌ 错误 - 禁止使用 @PathVariable @GetMapping("/dish/{id}") public RespInfo dishDetail(@PathVariable Long id) { ... }

      
      ### 4.2 权限标识
      
      - 格式:`模块:资源:操作`,如 `hotel:dish:query`、`hotel: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(含参数说明、业务规则、异常场景)
      - ServiceImpl 中复杂业务逻辑必须加块注释,说明业务背景和决策原因
      - 状态机流转、条件分支、边界处理等关键逻辑必须加行内注释
      - 跨模块调用、特殊设计决策需加注释说明"为什么这样做"
      - 禁止无意义注释(如 `// 获取名称` 紧跟 `getName()`),注释应解释意图而非复述代码
      
      ### 7.2 安全红线
      
      - 禁止硬编码密钥、AK/SK、数据库密码
      - 禁止在日志中打印手机号、身份证、银行卡
      - API Key/Secret 返回前端必须脱敏(保留前4后4,中间 `****`)
      
      ### 7.3 构建与验证(Windows PowerShell 环境)
      
      > **本环境关键约束**:
      > - Shell:PowerShell(不支持 `&&`,必须用 `;` 分隔命令)
      > - Maven:不在 PATH 中,必须用完整路径 `D:\java_install\apache-maven-3.9.16\bin\mvn.cmd`
      > - pnpm:执行策略禁止 `.ps1` 脚本,必须用 `.cmd` 后缀(`pnpm.cmd` 而非 `pnpm`)
      > - H5 构建脚本是 `build:h5`(不是 `build`)
      
      #### 后端命令
      
      ```powershell
      # 全量构建(从 forge-server 根目录)
      Set-Location D:\java_project\forge-admin\forge-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd clean install -DskipTests
      
      # 仅编译 hotel 模块及其依赖(快速验证编译)
      Set-Location D:\java_project\forge-admin\forge-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd compile -pl forge-business/forge-hotel -am -DskipTests
      
      # 启动 admin 服务(默认 localhost:8580)
      Set-Location D:\java_project\forge-admin\forge-server\forge-admin-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd spring-boot:run
      
      # 启动 flow 服务(默认 localhost:8581)
      Set-Location D:\java_project\forge-admin\forge-server\forge-flow; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd spring-boot:run
      

      前端命令(forge-admin-ui)

      Set-Location D:\java_project\forge-admin\forge-admin-ui
      
      # 安装依赖
      pnpm.cmd install
      
      # 开发模式(默认 localhost:5173)
      pnpm.cmd dev
      
      # 生产构建
      pnpm.cmd build
      
      # Lint 检查 + 自动修复
      pnpm.cmd lint:fix
      

      H5 命令(forge-h5-ui)

      Set-Location D:\java_project\forge-admin\forge-h5-ui
      
      # 安装依赖
      pnpm.cmd install
      
      # 开发模式
      pnpm.cmd dev:h5
      
      # 生产构建(⚠️ 必须是 build:h5,不是 build)
      pnpm.cmd build:h5
      

      PowerShell 常见坑(AI 执行前必读)

      问题 错误写法 正确写法
      命令链分隔 cd path && mvn ... Set-Location path; mvn.cmd ...
      Maven 不在 PATH mvn clean install D:\java_install\apache-maven-3.9.16\bin\mvn.cmd clean install
      pnpm 被策略拦截 pnpm build pnpm.cmd build:h5
      H5 构建命令 pnpm build pnpm.cmd build:h5(build 不存在会报错)
      执行 .bin 脚本 node node_modules/.bin/vite build 用 pnpm.cmd 统一入口,不要直接调 node_modules 下的文件

      八、列表页 + 表单弹窗组件拆分习惯

      参考实现:views/restaurant/creditCustomer.vue(列表页)+ views/restaurant/components/CreditCustomerFormModal.vue(表单弹窗组件)

      8.1 拆分原则

      • 表单字段多时,表单代码写在独立组件文件,不与列表页混在同一个 vue;但交互上仍以弹窗形式打开,不做独立路由页面
      • 表单弹窗组件统一放 views/<模块>/components/ 目录:unplugin-vue-router 的 routesFolder 排除 **/components/**,不会生成多余路由,也无需注册 sys_resource 菜单
      • 命名 XxxFormModal.vue;props 固定 show(v-model)+ row(null 为新增),送审成功后 emit('saved', payload),刷新与后续衔接交给父页面
      • 仅当确实需要带参数的独立页面(如点菜详情 dishOrder)才用路由子页面 + sys_resource 隐藏菜单(resource_type=2、visible=0),且 component 字段必须与文件路径完全一致,否则 404

      8.2 列表页(父页面)写法

      • AiCrudPage 加 hide-add + #toolbar-start 插槽自定义新增按钮打开表单弹窗;不传 edit-schema,操作列编辑同样打开表单弹窗
      • 监听 saved 事件刷新并衔接流程: javascript function handleFormSaved(payload) { crudRef.value?.refresh() const origin = formRow.value // 新增或暂存:送审后直接衔接审批人选择;审批中编辑仅保存 if (!origin || origin.approvalStatus === '1') openSubmit({ id: payload.id, customerName: payload.customerName }) else message.success('保存成功') }

      8.3 表单弹窗组件写法

      • NModal preset="card" + 固定宽度(如 900px)、:mask-closable="false";标题按 row 区分新增/编辑
      • 表单体:NGrid :cols="2" + NFormItemGi,长文本字段 :span="2";日期用 NDatePicker + v-model:formatted-value + value-format="yyyy-MM-dd"
      • 用 row prop 区分新增/编辑:有 row 调 getXxxDetail(row.id) 回填;保存 payload 带 id 走同一个 save 接口
      • 填报类表单底部主按钮叫送审(不叫保存):送审 = 校验 + 保存,成功后 emit saved 并关闭,是否衔接审批人选择由父页面决定
      • ID 保持字符串直传,禁止转 Number

      九、流程(审批流)书写与配置习惯

      参考实现:挂账客户审批(restaurant/creditCustomer.vue + 后端 CreditCustomerServiceImpl + CreditCustomerFlowDefinition)

      9.1 流程详情展示:统一用公共组件 FlowProcessDetailModal

      • 组件路径:@/components/flow/FlowProcessDetailModal.vue,封装「业务信息区 + 流转记录(步骤条 + 表格)+ 流程图」,内部自动加载审批历史、格式化时间/历时、映射状态标签
      • 任何业务流程传参即用,禁止再写一套弹窗/时间轴/表格列代码: vue <FlowProcessDetailModal v-model:show="flowDetailVisible" :process-instance-id="row.processInstanceId" :finished="row.approvalStatus === '5'" business-title="客户信息" :business-items="businessItems" />
      • 参数约定:
        • process-instance-id:流程实例ID,核心参数,为空展示空态
        • finished:流程是否已结束(点亮步骤条「结束」节点)
        • business-items:[{ label, value, dictType? }],带 dictType 自动渲染 DictTag;需完全自定义业务区时用 #business 插槽
        • business-title / title / column:业务区标题 / 弹窗标题 / 业务区每行条数(默认 2)
      • 弹窗尺寸已内置百分比规范:width: 92%; max-width: 1600px,内容区 min-height: 72vh; max-height: 88vh; overflow: auto,业务页面不要自行覆盖
      • 列表页审批状态列可点击查看流程:DictTag 不接受 onClick,必须用 h('span', { style, onClick }, [h(DictTag, {...})]) 包裹
      • 审批历史统一由组件内部 flowApi.getProcessHistory(processInstanceId) 获取,业务页面禁止自行调用和维护历史数据

      9.2 业务表与流程绑定配置

      • 业务表必须含 business_key、process_instance_id 字段;businessKey 格式固定 <objectCode>:<recordId>
      • 审批状态用数字字典值(如 1-暂存 2-审批中 3-已驳回 4-已退回 5-已完成),字典类型按业务命名(如 restaurant_credit_approval_status);前端一律 useDict + DictTag 字典驱动,禁止硬编码选项/标签
      • 状态门禁用函数集中定义:canSubmit / canEdit / canDelete / canToggle,操作列按门禁渲染按钮
      • 流程模型懒创建:首次提交审批时 ensureFlowModel() 自动创建并发布;getModelByKey 查询必须过滤 del_flag = 0,否则已删除模型会导致重复创建
      • 业务状态维护依赖双回调:task-created / task-completed 回调 + PROCESS_COMPLETED 监听都要维护状态,不能只靠其一;状态写入必须幂等、可从活动任务节点修复
      • BPMN 节点表单、字段权限、审批人策略在流程设计器中维护;代码初始化逻辑不得覆盖设计器已编辑的 BPMN XML
      • 前端流程变量与 API payload 中 ID 一律字符串,禁止雪花 Long 转 JavaScript Number

      9.3 流程相关 Flyway 脚本习惯

      • 流程绑定种子数据(ai_business_binding 等)与菜单/字典同脚本维护,全部 NOT EXISTS 防重复、tenant_id = 1
      • 存量数据回填审批状态时,无流程Key的历史数据视为「已完成」,保证老数据不被门禁锁死
      • 字典值变更(如字符串改数字)必须同脚本更新业务表存量数据 + 删旧字典 + 插新字典,三步齐全

      9.4 审批详情弹窗:公共骨架 + 业务插件(formUrl 组件契约)

      参考实现:views/flow/todo.vue(公共骨架)+ views/restaurant/CreditCustomerApproveForm.vue(挂账业务插件)+ views/business/purchase-order-test.vue(采购单业务插件)

      架构结论

      • 审批详情弹窗(待办 / 已办 / 首页待办点入均为同一套,只读态靠 readOnly 切换)是「公共骨架 + 业务插件」结构:业务方只提供三样东西,其余全部是共用组件,审批弹窗本身零改动
      • 公共部分(禁止重写、禁止复制一套):
      区域 载体 数据来源
      弹窗骨架 + 右侧审批记录 @/components/flow/FlowTaskDetailShell.vue flowApi.getProcessHistory(processInstanceId)
      基本信息区(当前节点/流程名称/分类/发起人/部门/时间/状态) views/flow/todo.vue 内置模板 任务行数据(sys_flow_task join 模型)
      查看流程图 DingFlowViewer processInstanceId
      业务表单动态加载器 @/components/common/FlowBusinessForm.vue 按 formUrl 动态加载 @/views 下组件
      业务表单上下文接口 @/api/business-app 的 businessTaskFormContext 后端 BusinessFlowService 按 providerKey 路由到 Provider
      审批提交链路 completeBusinessTaskAction / 统一同意/驳回/转办接口 平台统一
      • 业务差异部分(每个流程一套,仅三样):
        1. 前端表单组件:由模型配置 formUrl 指定,FlowBusinessForm 用 import.meta.glob('@/views/**/*.vue') 按路径加载
        2. 后端 Provider:实现 BusinessCodeFormProvider(providerKey 与配置一致),提供字段目录 fields、记录数据 recordData、节点保存 saveContext
        3. 配置:sys_flow_model.form_json(formUrl / providerKey / formKey)、BPMN 节点、节点字段权限、审批策略(allowApprove / requireComment 等)
      • 数据流:打开弹窗 → 公共代码调上下文接口 → 后端按 providerKey 找 Provider 取业务数据 → 以 initialTaskContext 传入业务组件(组件禁止重复请求)→ 提交时业务组件 emit('submit', { action, comment, variables }) → 公共代码走统一审批接口

      formUrl 红线(实际踩过的坑)

      • formUrl 是 @/views 下的「组件路径」,不是路由地址:如 /restaurant/CreditCustomerApproveForm 对应 src/views/restaurant/CreditCustomerApproveForm.vue;FlowBusinessForm 按路径匹配(含大小写不敏感兜底、includes 匹配)
      • 严禁把 formUrl 指向列表页或其它业务页面:曾指向 creditCustomer.vue 导致整个 CRUD 列表被嵌进审批区,且审批意见、同意/驳回按钮全部缺失(列表页组件不提供审批契约)
      • formUrl 运行时优先级:sys_flow_node_config 节点配置 > sys_flow_model.form_json(顶层与 formRef 两处)> Provider 常量;改 Java 常量不影响存量模型,必须 Flyway 同步修复 form_json(参考 V1.0.139__fix_credit_customer_flow_form_url.sql,REPLACE 两处 + LIKE 防重复)
      • formType=business-code 且 formUrl 为空时降级为平台内置渲染(AiForm 字段目录 + 审批意见 + 按钮);有自定义组件时优先组件模式

      业务表单组件契约(照 CreditCustomerApproveForm.vue 仿写)

      • props 固定一套:taskId / businessKey / processInstanceId / taskDefKey / processDefKey / variables / approvalPolicy / initialTaskContext / readOnly / submitting / submittingAction;emits:submit、cancel
      • 数据加载顺序:initialTaskContext.recordData 直接用 → 不匹配则 useBusinessTaskFormContext().load(...) → 最后兜底业务详情接口;readOnly 模式隐藏操作区
      • 字段显隐/编辑一律走 useBusinessTaskFormContext 的 canShowField / canEditField;节点可编辑字段 = 组件按节点定义的编辑清单 ∩ 字段权限
      • 提交前先保存本节点可写字段(businessTaskForm.save),再 emit('submit', { action, comment, variables });variables 必须带网关变量(如 approvalResult: 'approve' | 'reject')与业务主键
      • cancel 事件由公共骨架绑定为关闭弹窗;组件内「关闭」按钮直接 emit('cancel')

      提交链路共用机制(todo.vue 内建,业务方零成本)

      • 审批意见可选 + 默认补全:意见不做必填;handleExternalFormSubmit(业务表单组件)与 submitApprove(内置表单)两条提交路径提交前统一走 resolveActionComment,留空时按动作补默认意见(DEFAULT_ACTION_COMMENTS:approve→同意、reject→驳回、return→退回),绕过前端 validateApprovalInput 与后端 policy.requireComment 的双拦截;不要改成放后端/BPMN 校验,否则存量模型全部要重部署
      • 批量/快捷同意精准拦截:assertQuickActionAllowed 仅在节点存在「必填可写字段」时拦截批量同意(hasRequiredWritableBusinessFormFields:writable && required && 非 readonly/disabled);无必填字段的 business-code 节点(如老板审核仅可选备注)允许批量同意/驳回;有必填字段的节点(如申请人修改 customerName 必填)仍引导进详情处理
      • 批量通道与详情审批等价:批量走 executeQuickAction → completeBusinessTaskAction,网关变量(approvalResult/approved)由后端 mergeActionVariables 在 complete 时补齐,不依赖前端组件传参,不存在网关走错风险

      审批区 UI 习惯(用户确认版)

      • 业务信息展示不用 n-descriptions(其表格列宽随内容伸缩,上下长短不一):用自定义 CSS Grid 均分两列(repeat(2, minmax(0, 1fr)) + 1px gap 做分隔线),label 固定 120px 灰底、值列 flex: 1 填满;长文本字段 grid-column: 1 / -1 通栏
      • 审批意见可选(不做必填校验)、label 加粗;操作按钮固定:转办(公共 slot)+ 同意 + 驳回修改(修改节点为保存并重提/终止申请)+ 关闭
      • 不加节点标题栏(如「老板审核」tag)与「当前节点只查看」类 alert 提示

      新流程接入清单

      1. 后端:XxxFlowDefinition(常量 + 字段目录 + 默认 formRef JSON)+ XxxCodeFormProvider + Service 双回调维护状态
      2. 前端:@/views/<模块>/XxxApproveForm.vue 按上述契约新建
      3. 配置:formUrl 指向新组件(form_json 顶层与 formRef 两处)、providerKey 与 Provider 一致
      4. 库中已存在的存量模型:Flyway 脚本同步替换 formUrl
      5. 验证:待办打开审批 → 信息区均分展示、审批意见与按钮齐全;已办只读打开无操作按钮

      9.5 提交端审批人自适应(有配置直接提交,没配置才选人)

      参考实现:CreditCustomerController#/requiresApproverSelection + CreditCustomerServiceImpl.requiresApproverSelection + creditCustomer.vue openSubmit

      • 交互规则:点「提交审批」先探测老板节点配置——已配置直接提交不弹任何框;仅当什么都没配置(或仍是旧 ${bossId} 变量表达式)才弹选人框(必选)
      • 判定规则(解析已部署模型 BPMN 节点开标签,与运行时真相一致):
      节点配置 requiresApproverSelection 提交端表现
      flowable:candidateGroups 非空(角色/岗位/部门候选组) false 直接提交
      flowable:candidateUsers 非空(候选人员) false 直接提交
      flowable:assignee 固定值(无 ${}) false 直接提交
      flowable:assignee 为发起人/上级/负责人等表达式 false 引擎自行解析,直接提交
      无候选人且无 assignee,或节点不存在 true 弹框选老板
      flowable:assignee="${bossId}"(旧模板) true 弹框选老板,提交传 approverId 写变量
      • 为什么以 BPMN 为准:设计器配置角色/人员/岗位最终落到 BPMN 的候选组/候选人/assignee 属性,运行时任务分配由 Flowable 按这些属性原生执行;sys_flow_node_config 只服务于设计器/AI 上下文与 calculateApprovers,不用于本判定
      • 双保险:前端探测只决定「弹不弹框」;后端 submitApproval 用同一判定再校验(approverId == null && requiresApproverSelection() 抛「请选择审批人」),绕过前端直接调接口也拦得住
      • 选人下拉数据源:复用 GET /system/user/page(兼容 records/list/rows 三种返回形态,label 优先 realName),业务侧不另建老板候选接口
      • 三处同步红线:改节点审批人方式(设计器改或代码改)时同步检查 ① 前端提交弹窗 ② 后端提交校验 ③ Java BPMN 种子模板(XxxFlowBpmn),缺一处新环境就会按种子模板走回旧路;同时保持 ensureFlowModel 不覆盖设计器已编辑的 BPMN(见 9.2)
      • 生效提示:flow 侧 Mapper XML / Java 改动需重启 flow 服务才生效(重启 admin 无效);业务侧 Java 改重启 admin;前端 HMR 即时生效

      十、AI 执行纪律(防止跑偏规则)

      本节是 AI 编程助手执行任务时的强制约束,防止在终端命令、代码调查等环节浪费时间。

      10.1 终端命令纪律

      1. 命令失败最多重试 1 次:第 1 次失败后,必须先查本节「7.3 PowerShell 常见坑」表,禁止盲目尝试不同写法
      2. 禁止探索文件系统找工具路径:Maven、Node、pnpm 等工具路径已记录在 7.3 节,直接用,不要 where.exe、Get-ChildItem、find 等搜索
      3. 禁止用 cmd /c 绕过 PowerShell 限制:所有命令必须用 PowerShell 原生语法(; 分隔 + .cmd 后缀)
      4. 构建命令统一用本文档记录的命令:不要自己猜测命令,先读 7.3 节复制粘贴

      10.2 任务执行纪律

      1. 严格按待办列表顺序执行:完成当前 Task 后直接进入下一个,不要“顺便看看”其他代码
      2. 不做任务范围外的调查:如果当前任务是「接入营业时间校验」,不要去核实「权限机制」「数据库表结构」等无关内容
      3. 调查必须有明确目标:只有在当前 Task 明确要求「确认某文件内容」时才去读代码,不要主动发散
      4. 遇到不确定的环境配置,先查记忆再查代码:工具路径、数据库连接等已在记忆中的信息,直接检索,不要花时间在文件系统中搜索

      10.3 时间分配纪律

      1. 单个终端命令耗时不超过 30 秒:如果构建/安装卡住超过 30 秒,检查是否命令写错,不要反复尝试
      2. 单个调查任务不超过 3 次工具调用:如果需要读超过 3 个文件才能确认一件事,说明调查范围太大,应该缩小范围或直接问用户
      3. 文档更新集中处理:所有文档回填集中在最后一个 Task 完成,不要每完成一个 Task 就去改文档