本文档记录个人在 Forge 项目中的编码习惯和偏好,所有代码编写(含 AI 辅助生成)必须遵循以下规范。
适用范围: forge-hotel 模块及后续所有业务模块 最后更新: 2026-09-28
@Autowired 字段注入,不使用 @RequiredArgsConstructor 构造器注入@Autowired 注解java
@RestController
@RequestMapping("/hotel/dish")
public class HotelDishController {
@Autowired
private HotelDishService hotelDishService;
}
@RequestParam@PathVariable(路径参数如 id)和确实无法用实体类表达的参数(如安全校验参数)才单独接收/{id}/status)属于操作类,可保持 @RequestParam// ❌ 错误 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/toggleparams 中:
```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();
QueryWrapper / UpdateWrapper(字符串列名)LambdaQueryWrapper / LambdaUpdateChainWrapper(方法引用)RespInfo 封装返回值RespInfo.success(data) 或 RespInfo.success()(无返回值时)RespInfo.error(msg) 或抛出 BusinessExceptionTenantEntity(自带 tenantId + 审计字段)@TableId(value = "id", type = IdType.ASSIGN_ID)(雪花算法)@TableLogic(value = "0", delval = "id"),类型为 Long(bigint),删除时写入主键 ID@Data + @EqualsAndHashCode(callSuper = true) + @Serial示例:
@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;
}
LambdaQueryWrapper 构建查询selectById、insert、updateById、deleteById 等 MyBatis-Plus 内置方法允许DataScopeInterceptor 按 mapperMethod 精确匹配改写 SQLAND del_flag = 0FOR UPDATE 语句禁止写 LIMIT(实际踩过的坑):租户拦截器 TenantLineInnerInterceptor 会用 JSqlParser 4.9 对所有查询 SQL 重新解析序列化,序列化时把 LIMIT 1 FOR UPDATE 重排为 FOR UPDATE LIMIT 1,MySQL 直接语法错误(审批同意/驳回时报 SQLSyntaxErrorException);纯 FOR UPDATE(无 LIMIT)往返不变
WHERE 唯一条件 + FOR UPDATE,不加 LIMIT,并在 XML 中加防回归注释;条件不唯一时先把条件改唯一,而不是靠 LIMIT 兜FlowTaskMapper.selectByTaskIdForUpdate、CapabilityApprovalMapper.selectForUpdate@OperationLog 注解@OperationLog(module = "模块名", type = OperationType.XXX, desc = "操作描述")QUERY / ADD / UPDATE / DELETEcontroller/ # REST 控制器
domain/ # 数据库实体(Entity)
dto/ # 请求 DTO(新增/修改用)
vo/ # 响应 VO(查询返回用)
mapper/ # MyBatis Mapper 接口
service/ # 服务接口
└── impl/ # 服务实现
constant/ # 常量
@Transactional(rollbackFor = Exception.class)validateXxx(Xxx entity/dto)requireXxx(Long id)(不存在直接抛异常)AiCrudPage 组件(自动处理搜索栏、工具栏、表格、分页布局)height: 100%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',
}"
:id,ID 由组件自动作为查询参数传递tableColumns 和 editSchema 必须定义为 computed(确保字典异步加载后响应式更新)h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: ... }) 模式crudRef.value?.refresh()(不是 reload;等价于 loadList)crudRef.value?.handleEdit(row)crudRef.value?.handleDelete(row)(组件自带二次确认弹窗与 :id 占位符替换;不存在 openDelete,误调会点击即抛 TypeError)crudRef.value?.getTableData()(返回 dataSource,可用于轮询判断某行状态是否已变更,见 9.8)- 作为占位符100pxdiv 包裹 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 })
使用 UnoCSS 语义化颜色类区分操作类型:
| 类名 | 颜色 | 场景 |
|---|---|---|
text-primary |
蓝 | 编辑、查看、授权 |
text-warning |
黄 | 刷新缓存、重置、封禁 |
text-error |
红 | 删除、强制下线 |
text-success |
绿 | 启用、发布、通过 |
text-info |
灰蓝 | 详情、统计 |
DictSelect 组件或 useDict() composableDictTag 组件computed 包裹Teleport to="body")position: fixed + 半透明黑色背景max-width: 85vw + max-height: 80vhimport { request } from '@/utils'(不是 @/utils/request)getXxxPage / getXxxDetail / createXxx / updateXxx / deleteXxx// ==================== 模块名 ====================/hotel/dish/${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')
}
id, tenant_id, create_by, create_time, create_dept, update_by, update_timeutf8mb4,引擎 InnoDBbigint(雪花算法),不用自增bigint,单位分datetime(对应 Java LocalDateTime)varchar,值为大写英文(如 ENABLED / DISABLED、ON_SALE / OFF_SHELF)del_flag 字段类型 bigint NOT NULL DEFAULT 01)@TableLogic(value = "0", delval = "id")V<版本号>__<lower_snake_case_description>.sql1.0.0,单调递增CREATE TABLE IF NOT EXISTSINSERT ... SELECT ... WHERE NOT EXISTSinformation_schemaINSERT 必须显式写列名,禁止依赖表字段顺序tenant_id 必须为 1,禁止写 0parent_id(子查询),避免硬编码 IDsys_dict_type + sys_dict_data,dict_value 与后端枚举值保持一致hotel_ 前缀,系统级用 sys_ 前缀dict_value 必须与后端枚举/状态值严格一致dict_label 只负责展示文案list_class 负责标签样式(success/primary/warning/info/default)| 操作 | 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} 路径占位符@RequestParam 方式@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
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
Set-Location D:\java_project\forge-admin\forge-h5-ui
# 安装依赖
pnpm.cmd install
# 开发模式
pnpm.cmd dev:h5
# 生产构建(⚠️ 必须是 build:h5,不是 build)
pnpm.cmd build:h5
| 问题 | 错误写法 | 正确写法 |
|---|---|---|
| 命令链分隔 | 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(表单弹窗组件)
views/<模块>/components/ 目录:unplugin-vue-router 的 routesFolder 排除 **/components/**,不会生成多余路由,也无需注册 sys_resource 菜单XxxFormModal.vue;props 固定 show(v-model)+ row(null 为新增),送审成功后 emit('saved', payload),刷新与后续衔接交给父页面resource_type=2、visible=0),且 component 字段必须与文件路径完全一致,否则 404hide-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('保存成功')
}
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 接口Number参考实现:挂账客户审批(
restaurant/creditCustomer.vue+ 后端CreditCustomerServiceImpl+CreditCustomerFlowDefinition)
@/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) 获取,业务页面禁止自行调用和维护历史数据business_key、process_instance_id 字段;businessKey 格式固定 <objectCode>:<recordId>restaurant_credit_approval_status);前端一律 useDict + DictTag 字典驱动,禁止硬编码选项/标签canSubmit / canEdit / canWithdraw / canDelete / canToggle,操作列按门禁渲染按钮;审批状态数字值 ↔ 操作矩阵(以挂账客户为例,撤回见 9.6):1暂存→提交审批+编辑+删除;2审批中→撤回;3撤回 / 4驳回→编辑+删除;5已完成→变更挂账状态ensureFlowModel() 自动创建并发布;getModelByKey 查询必须过滤 del_flag = 0,否则已删除模型会导致重复创建PROCESS_COMPLETED 监听都要维护状态,不能只靠其一;状态写入必须幂等、可从活动任务节点修复Long 转 JavaScript Numberai_business_binding 等)与菜单/字典同脚本维护,全部 NOT EXISTS 防重复、tenant_id = 1参考实现:
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 / 统一同意/驳回/转办接口 |
平台统一 |
import.meta.glob('@/views/**/*.vue') 按路径加载BusinessCodeFormProvider(providerKey 与配置一致),提供字段目录 fields、记录数据 recordData、节点保存 saveContextsys_flow_model.form_json(formUrl / providerKey / formKey)、BPMN 节点、节点字段权限、审批策略(allowApprove / requireComment 等)initialTaskContext 传入业务组件(组件禁止重复请求)→ 提交时业务组件 emit('submit', { action, comment, variables }) → 公共代码走统一审批接口@/views 下的「组件路径」,不是路由地址:如 /restaurant/CreditCustomerApproveForm 对应 src/views/restaurant/CreditCustomerApproveForm.vue;FlowBusinessForm 按路径匹配(含大小写不敏感兜底、includes 匹配)creditCustomer.vue 导致整个 CRUD 列表被嵌进审批区,且审批意见、同意/驳回按钮全部缺失(列表页组件不提供审批契约)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 防重复)views/flow/done.vue)和通知(views/flow/notification.vue)页面与待办页面共用同一套组件架构,通过 readOnly 属性切换可写/只读态useBusinessManagedForm 分支):
FlowBusinessForm(businessFormContext.formUrl 存在时):加载同一个业务组件(如 CreditCustomerApproveForm.vue),传入 read-only=true,展示表格布局(与待办一致)AiForm(无 formUrl 时):用 readonlyBusinessFormFields(toReadonlyField 转换后的 schema)渲染,表单布局taskId / businessKey / processInstanceId / taskDefKey / processDefKey / variables / approvalPolicy / initialTaskContext / readOnly / submitting / submittingAction;emits:submit、cancelinitialTaskContext.recordData 直接用 → 不匹配则 useBusinessTaskFormContext().load(...) → 最后兜底业务详情接口;readOnly 模式隐藏操作区useBusinessTaskFormContext 的 canShowField / canEditField;节点可编辑字段 = 组件按节点定义的编辑清单 ∩ 字段权限businessTaskForm.save),再 emit('submit', { action, comment, variables });variables 必须带网关变量(如 approvalResult: 'approve' | 'reject')与业务主键cancel 事件由公共骨架绑定为关闭弹窗;组件内「关闭」按钮直接 emit('cancel')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 时补齐,不依赖前端组件传参,不存在网关走错风险n-descriptions(其表格列宽随内容伸缩,上下长短不一):用自定义 CSS Grid 均分两列(repeat(2, minmax(0, 1fr)) + 1px gap 做分隔线),label 固定 120px 灰底、值列 flex: 1 填满;长文本字段 grid-column: 1 / -1 通栏XxxFlowDefinition(常量 + 字段目录 + 默认 formRef JSON)+ XxxCodeFormProvider + Service 双回调维护状态@/views/<模块>/XxxApproveForm.vue 按上述契约新建参考实现:
CreditCustomerController#/requiresApproverSelection+CreditCustomerServiceImpl.requiresApproverSelection+creditCustomer.vue openSubmit
${bossId} 变量表达式)才弹选人框(必选)| 节点配置 | requiresApproverSelection | 提交端表现 |
|---|---|---|
flowable:candidateGroups 非空(角色/岗位/部门候选组) |
false | 直接提交 |
flowable:candidateUsers 非空(候选人员) |
false | 直接提交 |
flowable:assignee 固定值(无 ${}) |
false | 直接提交 |
flowable:assignee 为发起人/上级/负责人等表达式 |
false | 引擎自行解析,直接提交 |
| 无候选人且无 assignee,或节点不存在 | true | 弹框选老板 |
flowable:assignee="${bossId}"(旧模板) |
true | 弹框选老板,提交传 approverId 写变量 |
sys_flow_node_config 只服务于设计器/AI 上下文与 calculateApprovers,不用于本判定submitApproval 用同一判定再校验(approverId == null && requiresApproverSelection() 抛「请选择审批人」),绕过前端直接调接口也拦得住GET /system/user/page(兼容 records/list/rows 三种返回形态,label 优先 realName),业务侧不另建老板候选接口XxxFlowBpmn),缺一处新环境就会按种子模板走回旧路;同时保持 ensureFlowModel 不覆盖设计器已编辑的 BPMN(见 9.2)参考实现:
creditCustomer.vue handleWithdraw+flowApi.withdrawProcess+FlowTaskServiceImpl.withdraw+CreditCustomerServiceImpl.markCanceled
flowApi.withdrawProcess({ processInstanceId, userId, reason }) → POST /api/flow/task/withdraw(flow.js 是默认导出对象,非命名导出)
userId 取 useUserStore().userId(import { useUserStore } from '@/store')processInstanceId + userId + reason| 步骤 | 位置 | 动作 |
|---|---|---|
| 1 校验 | FlowTaskServiceImpl.assertSubmitterWithdrawAllowed |
校验 allowSubmitterWithdraw 属性非 FALSE + isProcessSubmitter 是提交人 |
| 2 删实例 | runtimeService.deleteProcessInstance(id, "用户撤回") |
物理删除运行时实例,FlowTask 状态置 6 |
| 3 事件 | FlowTaskEventListener case PROCESS_CANCELLED |
FlowBusiness 置 canceled + 发布 FlowEventMessage.PROCESS_CANCELED |
| 4 回调 | 业务 @FlowCallback(ON_CANCELED) |
markCanceled 把业务 approvalStatus 置为撤回态(如 '3') |
canWithdraw = row => row.approvalStatus === '2' && !!row.processInstanceId;按钮用 text-warning 黄色 + 二次确认creditSave 门禁放行 {1,3,4},且 3/4 保存后 reset 为 DRAFT)deleteProcessInstance 物理删除(ACT_RU_* 消失,痕迹留在 ACT_HI_* 与 sys_flow_task)submitApproval 再次 startProcess 生成新 processInstanceId,直接覆盖业务表 process_instance_idbusinessKey(<objectCode>:<recordId>)与业务记录 ID 不变;页面流程详情展示新实例时间轴,旧实例痕迹需走历史查询FlowInstanceServiceImpl.startProcess 原逻辑「sys_flow_business 存在非运行态记录 → 一律抛『业务流程已存在且不可重复发起』」,与前端 useFlow.canStart(canceled 后允许重发)自相矛盾,堵死了「发起→撤回→重发」闭环
isRestartableEndedBusiness,旧流程未成功走完(撤回/驳回/终止)且运行实例已不存在时,flowBusinessMapper.deleteById 清除旧关联再重发;该表只保留 businessKey 当前一轮关联参考实现:
creditCustomer.vue refreshUntilStatusChanged
FlowEventPublisher.publish(@Async + Redis Pub/Sub flow:event:all)→ admin 服务 FlowClientHelperAutoConfiguration 订阅 → @FlowCallback → 改业务状态javascript
function refreshUntilStatusChanged(rowId, expectedOldStatus, attempt = 0) {
crudRef.value?.refresh()
if (attempt >= 5) return
setTimeout(() => {
const latest = (crudRef.value?.getTableData() || []).find(i => String(i.id) === String(rowId))
if (!latest || latest.approvalStatus !== expectedOldStatus) return // 已流转或已删除则停
refreshUntilStatusChanged(rowId, expectedOldStatus, attempt + 1)
}, 800)
}
参考实现:
creditCustomer.vue挂账状态列(restaurant_credit_status三态)+CreditCustomerServiceImpl.creditUpdateStatus
creditToggleStatus 做 0→1→2→0 盲循环,前端按钮却按两态显示文案(status==='0' ? '暂停挂账' : '恢复正常')→ 从「暂停」点一下文案写「恢复正常」实际却跳「黑名单」,文案与行为不符 + 易误操作useDict),自动排除当前状态,点哪个变哪个creditUpdateStatus(id, creditStatus)),校验目标值必须是字典合法值,与当前相同则跳过;不再做循环推导本节是 AI 编程助手执行任务时的强制约束,防止在终端命令、代码调查等环节浪费时间。
where.exe、Get-ChildItem、find 等搜索cmd /c 绕过 PowerShell 限制:所有命令必须用 PowerShell 原生语法(; 分隔 + .cmd 后缀)