# 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 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 和 {id} @GetMapping("/dish/{id}") public RespInfo dishDetail(@PathVariable Long id) { ... } @PostMapping("/dish/remove/{id}") public RespInfo 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 boundShortCodes = new ArrayList<>(); for (HotelQrCode e : entities) { if (Objects.equals(e.getTenantId(), tenantId) && e.getRoomId() != null) { boundShortCodes.add(e.getShortCode()); } } // ❌ 错误 - Stream API List 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() { @Override public HotelQrCode get() { return getBaseMapper().selectByShortCodeIgnoreTenant(shortCode); } }); // ❌ 错误 - Lambda 表达式 HotelQrCode qrCode = TenantContextHolder.executeIgnore(() -> getBaseMapper().selectByShortCodeIgnoreTenant(shortCode) ); // ✅ 正确 - QueryWrapper + 字符串列名 QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("dish_id", dishId) .eq("tenant_id", tenantId) .orderByAsc("sort_order"); // ❌ 错误 - LambdaQueryWrapper + 方法引用 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(HotelDishSpecGroup::getDishId, dishId) .eq(HotelDishSpecGroup::getTenantId, tenantId) .orderByAsc(HotelDishSpecGroup::getSortOrder); // ✅ 正确 - UpdateWrapper + 字符串列名 UpdateWrapper 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 注释 - 示例: ```java @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 ``` ```javascript // 日期范围 → 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<版本号>__.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) ```powershell 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) ```powershell 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 ``` - 参数约定: - `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 格式固定 `:` - 审批状态用**数字字典值**(如 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 就去改文档